blob: 05fe598ef0f2d8438489a5ae4c498190c33fe763 [file] [log] [blame]
const { getInput, setFailed } = require('@actions/core');
const { context, getOctokit } = require("@actions/github");
// Keep this function in sync with the one in actions/bcr-pr-review-notifier/index.js
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;
}
// Keep this function in sync with the one in actions/bcr-pr-review-notifier/index.js
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 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}`);
// 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 myLogin = context.actor;
// 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 run() {
const token = getInput("token");
const octokit = getOctokit(token);
const { owner, repo } = context.repo;
// 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);
}
}
run().catch(err => {
console.error(err);
setFailed(err.message);
});