| const { getInput, setFailed } = require('@actions/core'); | |
| const { context, getOctokit } = require("@actions/github"); | |
| const path = require('path'); | |
| async function _processAllPrFiles(octokit, owner, repo, prNumber, fileProcessor) { | |
| // Fetch the PR's initial head commit SHA to ensure consistency. | |
| const { data: prInfo } = await octokit.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| const initialHeadSha = prInfo.head.sha; | |
| const accumulate = new Set(); | |
| const perPage = 100; // Max per_page value | |
| let page = 1; | |
| while (true) { | |
| const { data: files } = await octokit.rest.pulls.listFiles({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: perPage, | |
| page, | |
| }); | |
| // Safety Check: Re-fetch the PR to see if new commits have been pushed. | |
| const { data: latestPrInfo } = await octokit.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| if (initialHeadSha !== latestPrInfo.head.sha) { | |
| console.error( | |
| `PR #${prNumber} was updated while listing files. Aborting.`, | |
| `Initial SHA: ${initialHeadSha}, Current SHA: ${latestPrInfo.head.sha}` | |
| ); | |
| return null; | |
| } | |
| // Apply the specific processing logic for each file. | |
| files.forEach(file => fileProcessor(file, accumulate)); | |
| // Break the loop if this was the last page of results. | |
| if (files.length < perPage) { | |
| break; | |
| } | |
| page++; | |
| } | |
| return accumulate; | |
| } | |
| /** | |
| * Fetches all unique module versions (e.g., "rules_cc@1.2.3") that were modified in a PR. | |
| */ | |
| async function fetchAllModifiedModuleVersions(octokit, owner, repo, prNumber) { | |
| const fileProcessor = (file, accumulate) => { | |
| // Matches files like: modules/rules_cc/1.0.0/... | |
| const match = file.filename.match(/^modules\/([^\/]+)\/([^\/]+)\//); | |
| if (match) { | |
| accumulate.add(`${match[1]}@${match[2]}`); | |
| } | |
| }; | |
| return await _processAllPrFiles(octokit, owner, repo, prNumber, fileProcessor); | |
| } | |
| /** | |
| * Fetches all unique modules (e.g., "rules_cc") that had their metadata.json file modified. | |
| */ | |
| async function fetchAllModulesWithMetadataChange(octokit, owner, repo, prNumber) { | |
| const fileProcessor = (file, accumulate) => { | |
| // Matches files like: modules/rules_cc/metadata.json | |
| const match = file.filename.match(/^modules\/([^\/]+)\/metadata\.json/); | |
| if (match) { | |
| accumulate.add(match[1]); | |
| } | |
| }; | |
| return await _processAllPrFiles(octokit, owner, repo, prNumber, fileProcessor); | |
| } | |
| async function generateMaintainersMap(octokit, owner, repo, modifiedModules, toNotifyOnly, prNumber) { | |
| const maintainersMap = new Map(); // Map: maintainer GitHub username (lowercase) -> Set of module they maintain | |
| const modulesWithoutGithubMaintainers = new Set(); // Set of module names without module maintainers | |
| const userIdToName = new Map(); // Map: github_user_id -> username; serves as a cache | |
| 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; | |
| for (const maintainer of metadata.maintainers) { | |
| // Only add maintainers with a github handle set. When `toNotifyOnly`, also exclude those who have set "do_not_notify" | |
| if (!maintainer.github || (toNotifyOnly && maintainer["do_not_notify"])) { | |
| continue; | |
| } | |
| hasGithubMaintainer = true; | |
| if (!userIdToName.has(maintainer.github_user_id)) { | |
| try { | |
| const { data: user } = await octokit.request('GET /user/{account_id}', { | |
| account_id: maintainer.github_user_id, | |
| }); | |
| userIdToName.set(maintainer.github_user_id, user.login); | |
| maintainersMap.set(user.login.toLowerCase(), new Set()); | |
| } catch (error) { | |
| console.error(`Error fetching maintainer with GitHub ID ${maintainer.github_user_id}: ${error}`); | |
| setFailed(`Error fetching maintainer with GitHub ID ${maintainer.github_user_id}: ${error}`); | |
| return; | |
| } | |
| } | |
| const actualName = userIdToName.get(maintainer.github_user_id); | |
| maintainersMap.get(actualName.toLowerCase()).add(moduleName); | |
| // Detect if there's a mismatch between the github username in metadata.json and what GitHub reports | |
| if (actualName.toLowerCase() !== maintainer.github.toLowerCase() && prNumber) { | |
| console.log(`Detected GitHub username mismatch for user ID ${maintainer.github_user_id}: metadata.json has '${maintainer.github}', but GitHub has '${actualName}'.`); | |
| const commentBody = `⚠️ **GitHub Username Mismatch Detected**\n\n` + | |
| `The BCR registry entry for module **${moduleName}** contains an outdated GitHub username (\`@${maintainer.github}\`) that does not match their current active GitHub username (\`@${actualName}\`).\n\n` + | |
| `Please update the \`metadata.json\` file of **${moduleName}** to use the up-to-date username \`"${actualName}"\` to ensure proper review routing and notifications.`; | |
| await postComment(octokit, owner, repo, prNumber, commentBody); | |
| } | |
| } | |
| 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 requestBcrMaintainers(octokit, owner, repo, prNumber) { | |
| const reviews = await octokit.rest.pulls.listReviews({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 1, | |
| }); | |
| const { data: prInfo } = await octokit.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| const alreadyHasReviewer = prInfo.requested_reviewers.length > 0 || prInfo.requested_teams.length > 0; | |
| if (alreadyHasReviewer) { | |
| console.log('Skipping requesting BCR maintainers as there is already a reviewer assigned.'); | |
| return; | |
| } | |
| console.log('Requesting review from BCR maintainers'); | |
| await octokit.rest.pulls.requestReviewers({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| team_reviewers: ['bcr-maintainers'], | |
| }); | |
| } | |
| 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.toLowerCase(); | |
| // If there are too many maintainers, it's likely to be an accidental change that will spam too many people. | |
| if (moduleListToMaintainers.size > 10) { | |
| console.log(`Skipping notifying maintainers as there are too many maintainers: ${moduleListToMaintainers.size}`); | |
| // Notify the PR author that too many modules are modified | |
| const commentBody = `Hello @${prAuthor}, too many modules have been updated in this PR. If this is intended, please consider breaking it down into multiple PRs.`; | |
| await postComment(octokit, owner, repo, prNumber, commentBody); | |
| return; | |
| } | |
| 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) { | |
| // If all maintainers are skipped, we should notify the BCR maintainers | |
| await postComment(octokit, owner, repo, prNumber, | |
| `Hello BCR maintainers, modules (${modulesList}) have been updated in this PR. | |
| Please review the changes. You can view a diff against the previous version in the "Generate module diff" check.`); | |
| await requestBcrMaintainers(octokit, owner, repo, prNumber); | |
| 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. You can view a diff against the previous version in the "Generate module diff" check.`; | |
| 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 | |
| const existingComments = await octokit.paginate(octokit.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const commentExists = existingComments.some(comment => comment.body === body); | |
| if (commentExists) { | |
| console.log('Skipping comment as it\'s already posted for the PR.'); | |
| 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 all commits for the PR | |
| const commits = await octokit.paginate(octokit.rest.pulls.listCommits, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| // Determine review freshness by the commit a review was actually submitted | |
| // against (its `commit_id`), not by comparing timestamps against the head | |
| // commit's date. A commit's author date is fully attacker-controlled | |
| // (GIT_AUTHOR_DATE / `git commit --date`) and GitHub preserves it on push, | |
| // so a timestamp cutoff can be defeated by backdating a newly pushed head | |
| // commit to before an earlier approval: the stale approval then looks "fresh" | |
| // for a HEAD the reviewer never saw. A review only vouches for the exact | |
| // commit it was submitted against, so it counts iff its `commit_id` equals the | |
| // PR's current head SHA. This is timestamp-free and also covers the | |
| // merge-commit case (the head is the head regardless of parent count). | |
| const latestCommit = commits[commits.length - 1]; | |
| const headSha = latestCommit.sha; | |
| console.log(`Latest commit (PR head): ${headSha}`); | |
| // Get all review events for the PR | |
| const reviewEvents = await octokit.paginate(octokit.rest.pulls.listReviews, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| // For each reviewer, collect their latest review that was submitted against the | |
| // current PR head commit. | |
| // Key: reviewer, Value: review | |
| const latestReviews = new Map(); | |
| reviewEvents.forEach(review => { | |
| if (review.commit_id !== headSha) { | |
| return; | |
| } | |
| const reviewer = review.user.login.toLowerCase(); | |
| if (!latestReviews.has(reviewer)) { | |
| latestReviews.set(reviewer, review); | |
| return; | |
| } | |
| const existingSubmittedAt = new Date(latestReviews.get(reviewer).submitted_at); | |
| const 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.toLowerCase()); | |
| } | |
| }); | |
| // Print out the approvers | |
| console.log(`Approvers: ${Array.from(approvers).join(', ')}`); | |
| return approvers; | |
| } | |
| async function checkIfAllModifiedModulesApproved(modifiedModules, maintainersMap, approvers, prAuthor) { | |
| let allModulesApproved = true; | |
| let anyModuleApproved = false; | |
| const modulesNotApproved = []; | |
| for (const module of modifiedModules) { | |
| let moduleApproved = false; | |
| for (const [maintainer, maintainedModules] of maintainersMap.entries()) { | |
| if (maintainedModules.has(module)) { | |
| if (approvers.has(maintainer)) { | |
| moduleApproved = true; | |
| console.log(`Module '${module}' has maintainers' approval from '${maintainer}'.`); | |
| break; | |
| } | |
| if (prAuthor === maintainer) { | |
| moduleApproved = true; | |
| console.log(`Module '${module}' has maintainers' approval from the PR author '${prAuthor}'.`); | |
| break; | |
| } | |
| } | |
| } | |
| if (moduleApproved) { | |
| anyModuleApproved = true; | |
| } else { | |
| 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, anyModuleApproved }; | |
| } | |
| async function hasContributedBefore(octokit, owner, repo, prAuthor) { | |
| try { | |
| const { data: searchResult } = await octokit.rest.search.issuesAndPullRequests({ | |
| q: `is:pr is:merged author:${prAuthor} repo:${owner}/${repo}`, | |
| per_page: 1, | |
| }); | |
| return searchResult.total_count > 0; | |
| } catch (error) { | |
| console.error(`Failed to check if ${prAuthor} has contributed before: ${error.message}`); | |
| return false; | |
| } | |
| } | |
| async function isModuleMaintainer(octokit, owner, repo, prAuthorId) { | |
| try { | |
| const { data: searchResult } = await octokit.rest.search.code({ | |
| q: `user:${owner} repo:${repo} filename:metadata.json ${prAuthorId}`, | |
| per_page: 1, | |
| }); | |
| return searchResult.total_count > 0; | |
| } catch (error) { | |
| console.error(`Failed to check if user ID ${prAuthorId} is a module maintainer: ${error.message}`); | |
| return false; | |
| } | |
| } | |
| async function hasSensitiveMetadataChanges(octokit, owner, repo, prNumber, allModulesWithMetadataChange) { | |
| for (const moduleName of allModulesWithMetadataChange) { | |
| try { | |
| const { data: metadataContentMain } = await octokit.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: `modules/${moduleName}/metadata.json`, | |
| ref: 'main', | |
| }); | |
| const metadataMain = JSON.parse(Buffer.from(metadataContentMain.content, 'base64').toString('utf-8')); | |
| const { data: metadataContentHead } = await octokit.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: `modules/${moduleName}/metadata.json`, | |
| ref: `refs/pull/${prNumber}/head`, | |
| }); | |
| const metadataHead = JSON.parse(Buffer.from(metadataContentHead.content, 'base64').toString('utf-8')); | |
| metadataMain.versions = []; | |
| metadataHead.versions = []; | |
| if (JSON.stringify(metadataMain) !== JSON.stringify(metadataHead)) { | |
| return { isSensitive: true, isNewModule: false }; | |
| } | |
| } catch (error) { | |
| if (error.status === 404) { | |
| return { isSensitive: true, isNewModule: true }; | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| return { isSensitive: false, isNewModule: false }; | |
| } | |
| 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; | |
| } | |
| if (prInfo.data.changed_files > 500) { | |
| console.log(`Skipping PR #${prNumber} as it has more than 500 file changes (${prInfo.data.changed_files}).`); | |
| await postComment(octokit, owner, repo, prNumber, | |
| `Hello BCR maintainers, this PR has more than 500 file changes. Manual review is required.`); | |
| await requestBcrMaintainers(octokit, owner, repo, prNumber); | |
| return; | |
| } | |
| // Fetch modified modules | |
| const modifiedModuleVersions = await fetchAllModifiedModuleVersions(octokit, owner, repo, prNumber); | |
| if (modifiedModuleVersions === null) { | |
| // This means the PR was updated while fetching files. | |
| console.log(`Aborting review for PR #${prNumber} because it was updated during file fetching.`); | |
| return; | |
| } | |
| const modifiedModules = new Set(Array.from(modifiedModuleVersions).map(module => module.split('@')[0])); | |
| 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, _] = await generateMaintainersMap(octokit, owner, repo, modifiedModules, /* toNotifyOnly= */ false, prNumber); | |
| console.log('Maintainers Map:'); | |
| for (const [maintainer, maintainedModules] of maintainersMap.entries()) { | |
| console.log(`- Maintainer: ${maintainer}, Modules: ${Array.from(maintainedModules).join(', ')}`); | |
| } | |
| // 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 prAuthor = prInfo.data.user.login.toLowerCase(); | |
| const { allModulesApproved, anyModuleApproved } = await checkIfAllModifiedModulesApproved(modifiedModules, maintainersMap, approvers, prAuthor); | |
| // Fetch modules with metadata changes | |
| const allModulesWithMetadataChange = await fetchAllModulesWithMetadataChange(octokit, owner, repo, prNumber); | |
| if (allModulesWithMetadataChange === null) { | |
| console.log(`Aborting review for PR #${prNumber} because it was updated during file fetching.`); | |
| return; | |
| } | |
| let hasSensitiveMetadataChange = false; | |
| let isNewModule = false; | |
| try { | |
| const result = await hasSensitiveMetadataChanges(octokit, owner, repo, prNumber, allModulesWithMetadataChange); | |
| hasSensitiveMetadataChange = result.isSensitive; | |
| isNewModule = result.isNewModule; | |
| } catch (error) { | |
| console.error(`Error checking metadata.json sensitive revisions: ${error}`); | |
| return; | |
| } | |
| // Re-fetch PR information to check if new commits were pushed since analysis started | |
| const initialHeadSha = prInfo.data.head.sha; | |
| const latestPrInfoForShaCheck = await octokit.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| const currentHeadSha = latestPrInfoForShaCheck.data.head.sha; | |
| if (initialHeadSha !== currentHeadSha) { | |
| console.log(`PR #${prNumber} has been updated since the review process began. Initial SHA: ${initialHeadSha}, Current SHA: ${currentHeadSha}. Aborting approval/merge actions as the analysis may be stale.`); | |
| return; // Exit reviewPR for this PR to prevent actions on stale data | |
| } | |
| if (hasSensitiveMetadataChange && !isNewModule) { | |
| await postComment(octokit, owner, repo, prNumber, | |
| `Hello BCR maintainers, modules with sensitive metadata modifications (outside versions array) have been updated in this PR. Manual reviews are necessary.`); | |
| await requestBcrMaintainers(octokit, owner, repo, prNumber); | |
| } | |
| const { data } = await octokit.rest.users.getAuthenticated(); | |
| const myLogin = data.login.toLowerCase(); | |
| // GitHub doesn't allow reviewing your own PR, so PRs opened by the bot itself | |
| // (e.g. @bazel-io) must be approved by a BCR maintainer instead. | |
| const isSelfAuthored = prAuthor === myLogin; | |
| // Approve the PR if not previously approved and all modules are approved | |
| if (allModulesApproved && !hasSensitiveMetadataChange) { | |
| if (isSelfAuthored) { | |
| console.log(`Skipping approval: PR #${prNumber} was opened by ${myLogin}, which cannot approve its own PR.`); | |
| await postComment(octokit, owner, repo, prNumber, | |
| `Hello BCR maintainers, all modules in this PR have been approved by their maintainers, but this PR was opened by @${myLogin} and cannot be approved by itself. Please approve this PR so it can be merged.`); | |
| await requestBcrMaintainers(octokit, owner, repo, prNumber); | |
| } else if (!approvers.has(myLogin)) { | |
| console.log('Approving the PR'); | |
| await octokit.rest.pulls.createReview({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| event: 'APPROVE', | |
| body: 'All modules in this PR have been approved by their maintainers. This PR will be merged if all presubmit checks pass.', | |
| }); | |
| } | |
| // Try to merge the PR | |
| try { | |
| await octokit.rest.pulls.merge({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| // Pin the merge to the exact commit that was analyzed and approved. If | |
| // new commits were pushed in the window between the staleness check | |
| // above and this merge, GitHub rejects the merge (409) instead of | |
| // merging unreviewed content. | |
| sha: currentHeadSha, | |
| merge_method: 'squash', | |
| }); | |
| // Add auto-merged label | |
| await octokit.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels: ['auto-merged'], | |
| }); | |
| console.log(`PR ${prNumber} merged successfully`); | |
| return; | |
| } catch (error) { | |
| console.error('Failed to merge PR:', error.message); | |
| console.error('This PR is not mergeable probably due to failed presubmit checks.'); | |
| } | |
| } | |
| // Add presubmit-auto-run label if conditions are met | |
| const hasLabel = prInfo.data.labels.some(label => label.name === 'presubmit-auto-run'); | |
| if (!hasLabel) { | |
| const contributedBefore = await hasContributedBefore(octokit, owner, repo, prAuthor); | |
| const isMaintainer = await isModuleMaintainer(octokit, owner, repo, prInfo.data.user.id); | |
| if (contributedBefore && (anyModuleApproved || isMaintainer)) { | |
| await octokit.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels: ['presubmit-auto-run'], | |
| }); | |
| console.log(`Added presubmit-auto-run label to PR #${prNumber}`); | |
| } | |
| } | |
| // Add low-ci-priority label if more than 10 modules are modified | |
| const hasLowCiPriorityLabel = prInfo.data.labels.some(label => label.name === 'low-ci-priority'); | |
| if (!hasLowCiPriorityLabel && modifiedModules.size > 10) { | |
| await octokit.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels: ['low-ci-priority'], | |
| }); | |
| console.log(`Added low-ci-priority label to PR #${prNumber} because it modifies ${modifiedModules.size} modules`); | |
| } | |
| // Discard previous approvals if not all modules are approved | |
| if (!allModulesApproved && !isSelfAuthored && 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; | |
| 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 modifiedModuleVersions = await fetchAllModifiedModuleVersions(octokit, owner, repo, prNumber); | |
| if (modifiedModuleVersions === null) { | |
| // This means the PR was updated while fetching files. | |
| console.log(`Aborting notifier for PR #${prNumber} because it was updated during file fetching.`); | |
| return; | |
| } | |
| const modifiedModules = new Set(Array.from(modifiedModuleVersions).map(module => module.split('@')[0])); | |
| console.log(`Modified modules: ${Array.from(modifiedModules).join(', ')}`); | |
| // Figure out maintainers for each modified module | |
| const [maintainersMap, modulesWithoutGithubMaintainers] = await generateMaintainersMap(octokit, owner, repo, modifiedModules, /* toNotifyOnly= */ true, prNumber); | |
| // 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(`Requesting review from BCR maintainers for modules: ${modulesList}`); | |
| await postComment(octokit, owner, repo, prNumber, | |
| `Hello BCR maintainers, modules without existing maintainers (${modulesList}) have been updated in this PR. | |
| Please review the changes. You can view a diff against the previous version in the "Generate module diff" check.`); | |
| await requestBcrMaintainers(octokit, owner, repo, prNumber); | |
| } | |
| } | |
| 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.paginate(octokit.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: 'open', | |
| }); | |
| const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000); | |
| const failedPrs = []; | |
| // Review each PR | |
| for (const pr of prs) { | |
| let isOughtToBeReviewed = new Date(pr.updated_at) >= sixHoursAgo; | |
| // If not active by PR timestamp, check CI activity | |
| if (!isOughtToBeReviewed) { | |
| try { | |
| console.log(`PR #${pr.number} is inactive by timestamp. Checking CI status...`); | |
| const { data: checkRunsData } = await octokit.rest.checks.listForRef({ | |
| owner, | |
| repo, | |
| ref: pr.head.sha, | |
| }); | |
| // Check if any check run completed or started in the last 6 hours | |
| const hasRecentCiActivity = checkRunsData.check_runs.some(run => { | |
| const timeToCompare = run.completed_at ? new Date(run.completed_at) : new Date(run.started_at); | |
| return timeToCompare >= sixHoursAgo; | |
| }); | |
| if (hasRecentCiActivity) { | |
| console.log(`PR #${pr.number} has recent CI activity. Proceeding with review.`); | |
| isOughtToBeReviewed = true; | |
| } | |
| } catch (error) { | |
| console.error(`Failed to fetch check runs for PR #${pr.number}: ${error.message}`); | |
| } | |
| } | |
| if (isOughtToBeReviewed) { | |
| try { | |
| await reviewPR(octokit, owner, repo, pr.number); | |
| } catch (error) { | |
| // Don't let a single PR stop the review of all other PRs. | |
| console.error(`Failed to review PR #${pr.number}: ${error.message}`); | |
| failedPrs.push(pr.number); | |
| } | |
| } else { | |
| console.log(`Skipping PR #${pr.number} as it has no activity in the past 6 hours.`); | |
| } | |
| } | |
| if (failedPrs.length > 0) { | |
| setFailed(`Failed to review PRs: ${failedPrs.map(n => `#${n}`).join(', ')}`); | |
| } | |
| } | |
| 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.paginate(octokit.rest.pulls.listReviews, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| const reviewersToReRequest = new Set(); | |
| for (const review of reviews) { | |
| 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.', | |
| }); | |
| reviewersToReRequest.add(review.user.login); | |
| } | |
| } | |
| if (reviewersToReRequest.size > 0) { | |
| const { data: authenticatedUser } = await octokit.rest.users.getAuthenticated(); | |
| reviewersToReRequest.delete(authenticatedUser.login); | |
| } | |
| for (const reviewer of reviewersToReRequest) { | |
| try { | |
| console.log(`Re-requesting review from: ${reviewer}`); | |
| await octokit.rest.pulls.requestReviewers({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| reviewers: [reviewer], | |
| }); | |
| } catch (error) { | |
| console.warn(`Could not re-request review from ${reviewer}: ${error.message}. They might not be a collaborator.`); | |
| } | |
| } | |
| } | |
| const SKIP_CHECK_TRIGGER = "@bazel-io skip_check "; | |
| const ABANDON_PR_TRIGGER = "@bazel-io abandon"; | |
| async function runSkipCheck(octokit) { | |
| const payload = context.payload; | |
| const commentBody = payload.comment.body.trim(); | |
| if (!commentBody.startsWith(SKIP_CHECK_TRIGGER)) { | |
| return; | |
| } | |
| const check = commentBody.slice(SKIP_CHECK_TRIGGER.length); | |
| const owner = payload.repository.owner.login; | |
| const repo = payload.repository.name; | |
| if (check == "unstable_url") { | |
| await octokit.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: payload.issue.number, | |
| labels: ["skip-url-stability-check"], | |
| }); | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: '+1', | |
| }); | |
| } else if (check == "compatibility_level") { | |
| await octokit.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: payload.issue.number, | |
| labels: ["skip-compatibility-level-check"], | |
| }); | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: '+1', | |
| }); | |
| } else if (check == "incompatible_flags") { | |
| await octokit.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: payload.issue.number, | |
| labels: ["skip-incompatible-flags-test"], | |
| }); | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: '+1', | |
| }); | |
| } else { | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: 'confused', | |
| }); | |
| console.error(`unknown check: ${check}`); | |
| setFailed(`unknown check: ${check}`); | |
| } | |
| } | |
| async function runHandleComment(octokit) { | |
| const payload = context.payload; | |
| if (payload.comment.body.trim() !== ABANDON_PR_TRIGGER) { | |
| return; | |
| } | |
| const commenter = payload.comment.user.login.toLowerCase(); | |
| const prNumber = context.issue.number; | |
| const { owner, repo } = context.repo; | |
| // Fetch modified modules | |
| const modifiedModuleVersions = await fetchAllModifiedModuleVersions(octokit, owner, repo, prNumber); | |
| const modifiedModules = new Set(Array.from(modifiedModuleVersions).map(module => module.split('@')[0])); | |
| console.log(`Modified modules: ${Array.from(modifiedModules).join(', ')}`); | |
| if (modifiedModules.size === 0) { | |
| console.log('No modules are modified in this PR, cannot decide on maintainers.'); | |
| // React with confused, as this command should only be used on PRs that modify modules. | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: 'confused', | |
| }); | |
| return; | |
| } | |
| // Figure out maintainers for each modified module | |
| const [ maintainersMap, _ ] = await generateMaintainersMap(octokit, owner, repo, modifiedModules, /* toNotifyOnly= */ false); | |
| const isMaintainer = maintainersMap.has(commenter); | |
| if (isMaintainer) { | |
| console.log(`Closing PR #${prNumber} as requested by maintainer @${commenter}.`); | |
| await octokit.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| body: `This PR is being closed as requested by @${commenter}, who is a maintainer of the modified module(s).`, | |
| }); | |
| await octokit.rest.pulls.update({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| state: 'closed', | |
| }); | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: '+1', | |
| }); | |
| } else { | |
| console.log(`@${commenter} is not a maintainer of any modified modules, ignoring the abandon command.`); | |
| await octokit.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| body: `@${commenter}, you don't have permissions to abandon this PR since you are not a maintainer of any of the modified modules.`, | |
| }); | |
| await octokit.rest.reactions.createForIssueComment({ | |
| owner, | |
| repo, | |
| comment_id: payload.comment.id, | |
| content: 'confused', | |
| }); | |
| } | |
| } | |
| /** | |
| * Returns the directory to diff for `version` of `moduleName`, or null if `version` | |
| * does not name a directory directly inside `modules/<moduleName>/`. | |
| * | |
| * Version strings reaching this function are read from the metadata.json of the PR | |
| * under review, so they are controlled by whoever opened the PR. Without this check | |
| * a value such as "../../.." makes the caller run `diff` against a directory outside | |
| * the module, and `diff`'s output is printed to the workflow log. | |
| */ | |
| function moduleVersionDir(moduleName, version) { | |
| const moduleDir = path.resolve('modules', moduleName); | |
| const versionDir = path.resolve(moduleDir, version); | |
| if (path.dirname(versionDir) !== moduleDir) { | |
| return null; | |
| } | |
| return `modules/${moduleName}/${version}`; | |
| } | |
| async function runDiffModule(octokit) { | |
| const prNumber = context.issue.number; | |
| if (!prNumber) { | |
| console.log('Could not get pull request number from context, exiting'); | |
| return; | |
| } | |
| console.log(`Diffing modules in PR #${prNumber}`); | |
| const { owner, repo } = context.repo; | |
| // Fetch modified modules | |
| const modifiedModuleVersions = await fetchAllModifiedModuleVersions(octokit, owner, repo, prNumber); | |
| if (modifiedModuleVersions === null) { | |
| // This means the PR was updated while fetching files. | |
| console.log(`Aborting module diff for PR #${prNumber} because it was updated during file fetching.`); | |
| return; | |
| } | |
| console.log(`Modified modules: ${Array.from(modifiedModuleVersions).join(', ')}`); | |
| // Use group if more than one module are modified | |
| const groupStart = modifiedModuleVersions.size === 1 ? "" : "::group::"; | |
| const groupEnd = modifiedModuleVersions.size === 1 ? "" : "::endgroup::"; | |
| for (const moduleVersion of modifiedModuleVersions) { | |
| const [moduleName, versionName] = moduleVersion.split('@'); | |
| try { | |
| const { data: metadataContent } = await octokit.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: `modules/${moduleName}/metadata.json`, | |
| ref: `refs/pull/${prNumber}/head`, | |
| }); | |
| const metadata = JSON.parse(Buffer.from(metadataContent.content, 'base64').toString('utf-8')); | |
| // Assuming metadata.versions is sorted in ascending order, otherwise bcr_validation.py checks will fail anyway. | |
| let versionIndex = metadata.versions.findIndex(version => version === versionName); | |
| if (versionIndex === -1) { | |
| console.error(`Version ${versionName} not found in metadata.json for module ${moduleName}`); | |
| console.log(`Metadata content for module ${moduleName}@${versionName}: ${JSON.stringify(metadata, null, 2)}`); | |
| setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`); | |
| return; | |
| } | |
| if (versionIndex === 0) { | |
| console.log(`No previous version to diff for module ${moduleName}@${versionName}`); | |
| continue; | |
| } | |
| const previousVersion = metadata.versions[versionIndex - 1]; | |
| const previousVersionDir = moduleVersionDir(moduleName, previousVersion); | |
| const currentVersionDir = moduleVersionDir(moduleName, versionName); | |
| if (previousVersionDir === null || currentVersionDir === null) { | |
| console.error(`Refusing to diff module ${moduleName}: a version does not name a directory inside modules/${moduleName}/ (previous: '${previousVersion}', current: '${versionName}')`); | |
| setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`); | |
| continue; | |
| } | |
| console.log(`${groupStart}Generating diff for module ${moduleName}@${versionName} against version ${previousVersion}`); | |
| const diffArgs = ['--color=always', '-urN', previousVersionDir, currentVersionDir]; | |
| console.log(`Running command: diff ${diffArgs.join(' ')}`); | |
| const { spawnSync } = require('child_process'); | |
| try { | |
| const result = spawnSync('diff', diffArgs, { | |
| encoding: 'utf8', | |
| stdio: ['inherit', 'pipe', 'pipe'] | |
| }); | |
| if (result.status === 0) { | |
| console.log(`No differences found!`); | |
| } else if (result.status === 1) { | |
| // diff command returns 1 when differences are found | |
| console.log(result.stdout); | |
| console.log(result.stderr); | |
| } else { | |
| setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`); | |
| console.error(result.stderr); | |
| throw new Error(`Diff command failed with status ${result.status}`); | |
| } | |
| } catch (error) { | |
| console.error(`Error executing diff: ${error.message}`); | |
| throw error; | |
| } | |
| console.log(`${groupEnd}`); | |
| } catch (error) { | |
| if (error.status === 404) { | |
| console.log(`Module ${moduleName} does not have a metadata.json file on the PR branch.`); | |
| } else { | |
| console.error(`Error processing module ${moduleName}: ${error}`); | |
| setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`); | |
| } | |
| } | |
| } | |
| } | |
| 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 if (action_type === "skip_check") { | |
| await runSkipCheck(octokit); | |
| } else if (action_type === "diff_module") { | |
| await runDiffModule(octokit); | |
| } else if (action_type === "handle_comment") { | |
| await runHandleComment(octokit); | |
| } else { | |
| console.log(`Unknown action type: ${action_type}`); | |
| } | |
| } | |
| if (require.main === module) { | |
| run().catch(err => { | |
| console.error(err); | |
| setFailed(err.message); | |
| }); | |
| } | |
| module.exports = { getPrApprovers, moduleVersionDir, reviewPR, runPrReviewer }; |