Don't let self-authored PRs break the BCR PR reviewer (#2904)
Fixes #2885
GitHub doesn't let you review your own PR. So when a BCR PR was opened
by @bazel-io (e.g. bazelbuild/bazel-central-registry#10700), the
`APPROVE` `createReview` call in `reviewPR` threw. The error also
escaped `runPrReviewer` and stopped the whole `review_prs` loop, which
meant no other open PRs got auto-reviewed either (e.g.
bazelbuild/bazel-central-registry#10699).
### Changes
- `reviewPR`: when the PR author is the authenticated bot account, the
reviewer doesn't call `createReview` (it skips both `APPROVE` and
`REQUEST_CHANGES`). If every module has its maintainers' approval, it
posts a comment (only once) and requests a review from `bcr-maintainers`
instead. It still tries to merge, so the PR gets merged on a later run
once a BCR maintainer approves.
- `runPrReviewer`: errors are caught per PR, so a failure on one PR no
longer blocks the rest. If any PR failed, the run is marked failed at
the end (`setFailed`) so the errors still show up.
- Export `reviewPR`/`runPrReviewer` and add unit tests for: the normal
approve+merge path, a self-authored PR, and one PR failing while the
loop keeps going.
### Testing
`cd actions/bcr-pr-reviewer && node --test`: all 12 tests pass.
Note: BCR workflows pin this action by SHA, so a follow-up BCR PR that
bumps the SHA is needed for this to take effect.
diff --git a/actions/bcr-pr-reviewer/index.js b/actions/bcr-pr-reviewer/index.js
index 543a38f..b0a3675 100644
--- a/actions/bcr-pr-reviewer/index.js
+++ b/actions/bcr-pr-reviewer/index.js
@@ -517,10 +517,18 @@
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 (!approvers.has(myLogin)) {
+ 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,
@@ -589,7 +597,7 @@
}
// Discard previous approvals if not all modules are approved
- if (!allModulesApproved && approvers.has(myLogin)) {
+ if (!allModulesApproved && !isSelfAuthored && approvers.has(myLogin)) {
console.log('Discarding previous approval');
await octokit.rest.pulls.createReview({
owner,
@@ -714,6 +722,7 @@
});
const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000);
+ const failedPrs = [];
// Review each PR
for (const pr of prs) {
@@ -745,11 +754,21 @@
}
if (isOughtToBeReviewed) {
- await reviewPR(octokit, owner, repo, pr.number);
+ 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) {
@@ -1078,4 +1097,4 @@
});
}
-module.exports = { getPrApprovers, moduleVersionDir };
+module.exports = { getPrApprovers, moduleVersionDir, reviewPR, runPrReviewer };
diff --git a/actions/bcr-pr-reviewer/index.test.js b/actions/bcr-pr-reviewer/index.test.js
index 61870e9..f449154 100644
--- a/actions/bcr-pr-reviewer/index.test.js
+++ b/actions/bcr-pr-reviewer/index.test.js
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');
-const { getPrApprovers, moduleVersionDir } = require('./index.js');
+const { getPrApprovers, moduleVersionDir, reviewPR, runPrReviewer } = require('./index.js');
function fakeOctokit({ commits, reviews }) {
return {
@@ -122,3 +122,123 @@
assert.equal(moduleVersionDir('rules_cc', '.'), null);
assert.equal(moduleVersionDir('rules_cc', ''), null);
});
+
+// A fake octokit for reviewPR/runPrReviewer on a PR touching one module
+// (`foo`) maintained by `alice`, who has approved the PR at its head commit.
+function fakeReviewOctokit({ prAuthor, prs = [], failingPrs = new Set() }) {
+ const calls = { createReview: [], createComment: [], requestReviewers: [], merge: [] };
+ const listCommits = Symbol('listCommits');
+ // getPrApprovers paginates listReviews while requestBcrMaintainers calls it
+ // directly, so it must be both a paginate marker and callable.
+ const listReviews = async () => ({ data: [] });
+ const listComments = Symbol('listComments');
+ const listPulls = Symbol('listPulls');
+ const prData = (pull_number) => ({
+ number: pull_number,
+ draft: false,
+ changed_files: 1,
+ user: { login: prAuthor, id: 42 },
+ head: { sha: 'HEAD' },
+ labels: [{ name: 'presubmit-auto-run' }],
+ requested_reviewers: [],
+ requested_teams: [],
+ });
+ const octokit = {
+ calls,
+ paginate: async (fn, params) => {
+ if (fn === listCommits) return [{ sha: 'HEAD' }];
+ if (fn === listReviews) {
+ return [{ user: { login: 'alice' }, state: 'APPROVED', commit_id: 'HEAD', submitted_at: '2026-09-30T00:00:00Z' }];
+ }
+ if (fn === listComments) return [];
+ if (fn === listPulls) return prs;
+ throw new Error('unexpected paginate call');
+ },
+ request: async (route, params) => {
+ assert.equal(route, 'GET /user/{account_id}');
+ return { data: { login: 'alice' } };
+ },
+ rest: {
+ pulls: {
+ get: async ({ pull_number }) => {
+ if (failingPrs.has(pull_number)) throw new Error(`boom #${pull_number}`);
+ return { data: prData(pull_number) };
+ },
+ list: listPulls,
+ listCommits,
+ listReviews,
+ listFiles: async () => ({ data: [{ filename: 'modules/foo/1.0.0/MODULE.bazel' }] }),
+ createReview: async (params) => {
+ calls.createReview.push(params);
+ if (prAuthor === 'bazel-io') {
+ const error = new Error('Unprocessable Entity: "Can not approve your own pull request"');
+ error.status = 422;
+ throw error;
+ }
+ },
+ requestReviewers: async (params) => { calls.requestReviewers.push(params); },
+ merge: async (params) => { calls.merge.push(params); },
+ },
+ issues: {
+ listComments,
+ createComment: async (params) => { calls.createComment.push(params); },
+ addLabels: async () => {},
+ },
+ repos: {
+ getContent: async ({ path }) => {
+ assert.equal(path, 'modules/foo/metadata.json');
+ const metadata = { maintainers: [{ github: 'alice', github_user_id: 1 }], versions: ['1.0.0'] };
+ return { data: { content: Buffer.from(JSON.stringify(metadata)).toString('base64') } };
+ },
+ },
+ users: {
+ getAuthenticated: async () => ({ data: { login: 'bazel-io' } }),
+ },
+ actions: {
+ listRepoWorkflows: async () => ({ data: { workflows: [{ id: 7, path: '.github/workflows/dismiss_approvals.yml' }] } }),
+ listWorkflowRuns: async () => ({ data: { workflow_runs: [], total_count: 0 } }),
+ },
+ },
+ };
+ return octokit;
+}
+
+test('reviewPR: approves and merges a fully approved PR opened by someone else', async () => {
+ const octokit = fakeReviewOctokit({ prAuthor: 'contributor' });
+
+ await reviewPR(octokit, 'owner', 'repo', 1);
+
+ assert.equal(octokit.calls.createReview.length, 1);
+ assert.equal(octokit.calls.createReview[0].event, 'APPROVE');
+ assert.equal(octokit.calls.merge.length, 1);
+});
+
+test('reviewPR: does not try to approve a PR opened by the bot itself and asks BCR maintainers instead', async () => {
+ const octokit = fakeReviewOctokit({ prAuthor: 'bazel-io' });
+
+ await reviewPR(octokit, 'owner', 'repo', 1);
+
+ assert.deepEqual(octokit.calls.createReview, [], 'GitHub rejects reviews on your own PR');
+ assert.equal(octokit.calls.createComment.length, 1);
+ assert.match(octokit.calls.createComment[0].body, /opened by @bazel-io and cannot be approved by itself/);
+ assert.deepEqual(octokit.calls.requestReviewers.map(r => r.team_reviewers), [['bcr-maintainers']]);
+ // Still attempts the merge, which succeeds once a BCR maintainer has approved.
+ assert.equal(octokit.calls.merge.length, 1);
+});
+
+test('runPrReviewer: a failure on one PR does not stop reviewing the remaining PRs', async () => {
+ const github = require('@actions/github');
+ github.context.payload = { repository: { owner: { login: 'owner' }, name: 'repo' } };
+ const now = new Date().toISOString();
+ const octokit = fakeReviewOctokit({
+ prAuthor: 'contributor',
+ prs: [{ number: 1, updated_at: now }, { number: 2, updated_at: now }],
+ failingPrs: new Set([1]),
+ });
+
+ await runPrReviewer(octokit);
+
+ assert.deepEqual(octokit.calls.merge.map(m => m.pull_number), [2]);
+ assert.equal(process.exitCode, 1, 'the run should still be marked as failed');
+ process.exitCode = 0;
+});