blob: 4339356e7b088d0e077c5578119820e4dbf647c9 [file] [log] [blame]
const { getInput, setFailed } = require('@actions/core');
const { context, getOctokit } = require("@actions/github");
async function fetchAllModifiedModules(octokit, owner, repo, prNumber) {
let page = 1;
const perPage = 100; // GitHub's max per_page value
let accumulate = new Set();
let response;
do {
response = await octokit.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: perPage,
page,
});
response.data.forEach(file => {
const match = file.filename.match(/^modules\/([^\/]+)\//);
if (match) {
accumulate.add(match[1]);
}
});
page++;
} while (response.data.length === perPage);
return accumulate;
}
async function generateMaintainersMap(octokit, owner, repo, modifiedModules) {
const maintainersMap = new Map(); // Map: maintainer GitHub username -> Set of module they maintain
const modulesWithoutGithubMaintainers = new Set(); // Set of module names without module maintainers
for (const moduleName of modifiedModules) {
console.log(`Fetching metadata for module: ${moduleName}`);
try {
const { data: metadataContent } = await octokit.rest.repos.getContent({
owner,
repo,
path: `modules/${moduleName}/metadata.json`,
ref: 'main',
});
const metadata = JSON.parse(Buffer.from(metadataContent.content, 'base64').toString('utf-8'));
let hasGithubMaintainer = false;
metadata.maintainers.forEach(maintainer => {
if (maintainer.github) { // Check if the github field is specified
hasGithubMaintainer = true;
if (!maintainersMap.has(maintainer.github)) {
maintainersMap.set(maintainer.github, new Set());
}
maintainersMap.get(maintainer.github).add(moduleName);
}
});
if (!hasGithubMaintainer) {
modulesWithoutGithubMaintainers.add(moduleName);
}
} catch (error) {
if (error.status === 404) {
console.log(`Module ${moduleName} does not have a metadata.json file on the main branch.`);
modulesWithoutGithubMaintainers.add(moduleName);
} else {
console.error(`Error processing module ${moduleName}: ${error}`);
setFailed(`Failed to notify maintainers for module ${moduleName}`);
}
}
}
return [maintainersMap, modulesWithoutGithubMaintainers];
}
async function notifyMaintainers(octokit, owner, repo, prNumber, maintainersMap) {
// For the list of maintainers who maintain the same set of modules, we want to group them together
const moduleListToMaintainers = new Map(); // Map: Serialized Module List -> Maintainers
// Populate moduleListToMaintainers based on maintainersMap
for (const [maintainer, modules] of maintainersMap.entries()) {
const modulesList = Array.from(modules).sort().join(', '); // Serialize module list
if (!moduleListToMaintainers.has(modulesList)) {
moduleListToMaintainers.set(modulesList, new Set());
}
moduleListToMaintainers.get(modulesList).add(`@${maintainer}`);
}
// Notify maintainers based on grouped module lists
const prAuthor = context.payload.pull_request.user.login;
for (const [modulesList, maintainers] of moduleListToMaintainers.entries()) {
// Skip notifying the PR author if they are one of the module maintainers
const maintainersCopy = new Set(maintainers);
if (maintainersCopy.has(`@${prAuthor}`)) {
console.log(`Skipping notifying PR author ${prAuthor} from the maintainers list for modules: ${modulesList}`);
maintainersCopy.delete(`@${prAuthor}`);
}
if (maintainersCopy.size === 0) {
continue;
}
const maintainersList = Array.from(maintainersCopy).join(', ');
console.log(`Notifying ${maintainersList} for modules: ${modulesList}`);
const commentBody = `Hello ${maintainersList}, modules you maintain (${modulesList}) have been updated in this PR. Please review the changes.`;
await postComment(octokit, owner, repo, prNumber, commentBody);
}
}
async function postComment(octokit, owner, repo, prNumber, body) {
// Check if the same comment already exists for the PR in the past two weeks
const existingComments = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
since: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), // Two weeks ago
});
const commentExists = existingComments.data.some(comment => comment.body === body);
if (commentExists) {
console.log('Skipping comment as it\'s already posted for the PR within the past two weeks.');
return;
}
const comment = {
owner,
repo,
issue_number: prNumber,
body,
};
await octokit.rest.issues.createComment(comment).catch(error => {
console.error(`Failed to post comment: ${error}`);
setFailed(`Failed to notify maintainers: ${error}`);
});
}
async function getPrApprovers(octokit, owner, repo, prNumber) {
// Get the commits for the PR
const commits = await octokit.rest.pulls.listCommits({
owner,
repo,
pull_number: prNumber,
});
// Filter out the merge commits whose parents length is larger than 1
const nonMergeCommits = commits.data.filter(commit => commit.parents.length === 1);
// Get the latest commit submitted time
const latestCommit = nonMergeCommits[nonMergeCommits.length - 1];
const latestCommitTime = new Date(latestCommit.commit.author.date);
console.log(`Latest commit: ${latestCommit.sha}`);
console.log(`Latest commit time: ${latestCommitTime}`);
// Get review events for the PR
const reviewEvents = await octokit.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
});
// For each reviewer, collect their latest review that are newer than the latest non-merge commit
// Key: reviewer, Value: review
const latestReviews = new Map();
reviewEvents.data.forEach(review => {
if (new Date(review.submitted_at) < latestCommitTime) {
return;
}
const reviewer = review.user.login;
if (!latestReviews.has(reviewer)) {
latestReviews.set(reviewer, review);
return;
}
existingSubmittedAt = new Date(latestReviews.get(reviewer).submitted_at);
submittedAt = new Date(review.submitted_at);
if (submittedAt > existingSubmittedAt) {
latestReviews.set(reviewer, review);
}
});
// Print out the latest valid reviews and collect approvers
console.log(`Latest Reviews:`);
const approvers = new Set();
latestReviews.forEach(review => {
console.log(`- Reviewer: ${review.user.login}, State: ${review.state}, Submitted At: ${review.submitted_at}`);
if (review.state === 'APPROVED') {
approvers.add(review.user.login);
}
});
// Print out the approvers
console.log(`Approvers: ${Array.from(approvers).join(', ')}`);
return approvers;
}
async function checkIfAllModifiedModulesApproved(modifiedModules, maintainersMap, approvers) {
let allModulesApproved = true;
const modulesNotApproved = [];
for (const module of modifiedModules) {
let moduleApproved = false;
for (const [maintainer, maintainedModules] of maintainersMap.entries()) {
if (maintainedModules.has(module) && approvers.has(maintainer)) {
moduleApproved = true;
console.log(`Module '${module}' has maintainers' approval from '${maintainer}'.`);
break;
}
}
if (!moduleApproved) {
allModulesApproved = false;
modulesNotApproved.push(module);
console.log(`Module '${module}' does not have maintainers' approval.`);
}
}
if (!allModulesApproved) {
console.log(`Cannot auto-merge this PR, the following modules do not have maintainers' approval: ${modulesNotApproved.join(', ')}`);
} else {
console.log('All modified modules have maintainers\' approval');
}
return allModulesApproved;
}
async function reviewPR(octokit, owner, repo, prNumber) {
console.log('\n');
console.log(`Processing PR #${prNumber}`);
// Skip if the PR is a draft
const prInfo = await octokit.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
if (prInfo.data.draft) {
console.log('Skipping draft PR');
return;
}
// Fetch modified modules
const modifiedModules = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
console.log(`Modified modules: ${Array.from(modifiedModules).join(', ')}`);
if (modifiedModules.size === 0) {
console.log('No modules are modified in this PR');
return;
}
// Figure out maintainers for each modified module
const [ maintainersMap, modulesWithoutGithubMaintainers ] = await generateMaintainersMap(octokit, owner, repo, modifiedModules);
console.log('Maintainers Map:');
for (const [maintainer, maintainedModules] of maintainersMap.entries()) {
console.log(`- Maintainer: ${maintainer}, Modules: ${Array.from(maintainedModules).join(', ')}`);
}
// If modulesWithoutGithubMaintainers is not empty, then return
if (modulesWithoutGithubMaintainers.size > 0) {
console.log(`Cannot auto-merge this PR with maintainers approval because the following modules do not have maintainers with GitHub usernames: ${Array.from(modulesWithoutGithubMaintainers).join(', ')}`);
return;
}
// Get the approvers for the PR
const approvers = await getPrApprovers(octokit, owner, repo, prNumber);
// Verify if all modified modules have at least one maintainer's approval
const allModulesApproved = await checkIfAllModifiedModulesApproved(modifiedModules, maintainersMap, approvers);
const { data } = await octokit.rest.users.getAuthenticated();
const myLogin = data.login;
// Approve the PR if not previously approved and all modules are approved
if (allModulesApproved) {
if (!approvers.has(myLogin)) {
console.log('Approving the PR');
await octokit.rest.pulls.createReview({
owner,
repo,
pull_number: prNumber,
event: 'APPROVE',
body: 'Hello @bazelbuild/bcr-maintainers, all modules in this PR have been approved by their maintainers. Please take a final look to merge this PR.',
});
}
// TODO: Enable the following when it's safe to do so.
// // Try to merge the PR
// try {
// await octokit.rest.pulls.merge({
// owner,
// repo,
// pull_number: prNumber,
// });
// } catch (error) {
// console.error('Failed to merge PR:', error.message);
// console.error('This PR is not mergeable probably due to failed presubmits.');
// }
}
// Discard previous approvals if not all modules are approved
if (!allModulesApproved && approvers.has(myLogin)) {
console.log('Discarding previous approval');
await octokit.rest.pulls.createReview({
owner,
repo,
pull_number: prNumber,
event: 'REQUEST_CHANGES',
body: 'Require module maintainers\' approval.',
});
}
}
async function runNotifier(octokit) {
const prNumber = context.issue.number;
if (!prNumber) {
console.log('Could not get pull request number from context, exiting');
return;
}
console.log(`Processing PR #${prNumber}`);
const { owner, repo } = context.repo;
// Fetch modified modules
const modifiedModules = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
console.log(`Modified modules: ${Array.from(modifiedModules).join(', ')}`);
// Figure out maintainers for each modified module
const [ maintainersMap, modulesWithoutGithubMaintainers ] = await generateMaintainersMap(octokit, owner, repo, modifiedModules);
// Notify maintainers for modules with module maintainers
await notifyMaintainers(octokit, owner, repo, prNumber, maintainersMap);
// Notify BCR maintainers for modules without module maintainers
if (modulesWithoutGithubMaintainers.size > 0) {
const modulesList = Array.from(modulesWithoutGithubMaintainers).join(', ');
console.log(`Notifying @bazelbuild/bcr-maintainers for modules: ${modulesList}`);
await postComment(octokit, owner, repo, prNumber, `Hello @bazelbuild/bcr-maintainers, modules without existing maintainers (${modulesList}) have been updated in this PR. Please review the changes.`);
}
}
async function waitForDismissApprovalsWorkflow(octokit, owner, repo) {
// List all workflow of this repository
const workflows = await octokit.rest.actions.listRepoWorkflows({
owner,
repo,
});
// Find the workflow file for .github/workflows/dismiss_approvals.yml
const dismissApprovalsWorkflow = workflows.data.workflows.find(workflow => workflow.path === '.github/workflows/dismiss_approvals.yml');
if (!dismissApprovalsWorkflow) {
setFailed('The dismiss_approvals workflow is not found');
return false;
}
console.log(`Found dismiss_approvals workflow: ${dismissApprovalsWorkflow.id}`);
// Wait until all runs of the dismiss_approvals workflow are completed
// https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository
shouldWaitStatues = ['queued', 'in_progress', 'requested', 'pending', 'waiting'];
let response;
while (true) {
total_count = 0;
for (const status of shouldWaitStatues) {
response = await octokit.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: dismissApprovalsWorkflow.id,
status: status,
});
for (const run of response.data.workflow_runs) {
console.log(`Dismiss approvals workflow run #${run.run_number} is ${run.status}`);
}
total_count += response.data.total_count;
}
if (total_count > 0) {
console.log('Waiting 5s for dismiss approvals workflow runs to complete');
await new Promise(resolve => setTimeout(resolve, 5000));
} else {
console.log('All dismiss approvals workflow runs are completed');
break;
}
}
return true;
}
async function runPrReviewer(octokit) {
const { owner, repo } = context.repo;
// Wait until all runs of the dismiss_approvals workflow are completed
if (!await waitForDismissApprovalsWorkflow(octokit, owner, repo)) {
return;
}
// Get all open PRs from the repo
const prs = await octokit.rest.pulls.list({
owner,
repo,
state: 'open',
});
// Review each PR
for (const pr of prs.data) {
await reviewPR(octokit, owner, repo, pr.number);
}
}
async function runDismissApproval(octokit) {
const prNumber = context.issue.number;
if (!prNumber) {
console.log('Could not get pull request number from context, exiting');
return;
}
console.log(`Processing PR #${prNumber}`);
const reviews = await octokit.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
for (const review of reviews.data) {
if (review.state === 'APPROVED') {
console.log(`Dismiss approval from ${review.user.login}`);
await octokit.rest.pulls.dismissReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
review_id: review.id,
message: 'Require module maintainers\' approval for newly pushed changes.',
});
}
}
}
async function run() {
const action_type = getInput("action-type");
const token = getInput("token");
const octokit = getOctokit(token);
if (action_type === "notify_maintainers") {
await runNotifier(octokit);
} else if (action_type === "review_prs") {
await runPrReviewer(octokit);
} else if (action_type === "dismiss_approvals") {
await runDismissApproval(octokit);
} else {
console.log(`Unknown action type: ${action_type}`);
}
}
run().catch(err => {
console.error(err);
setFailed(err.message);
});