Skip to content

Commit 8e98ae3

Browse files
committed
fix(review-checklist): prune CODEOWNERS avalanche regardless of which event wins the race
GitHub's CODEOWNERS engine fires one review_requested event per auto-assigned owner on PR creation, and each re-triggers this workflow. With concurrency: cancel-in-progress, whichever run starts last wins — and that's just as likely to be one of those review_requested runs as the opened run itself. A surviving review_requested run fell through to the additive-fill branch, saw every area already "covered" by the avalanche, and pruned nothing. This hit PR #6479 itself: all 4 CODEOWNERS for .github/ stayed requested. Branch on whether a checklist comment exists yet instead of which action survived — that signal is race-resistant: no comment means this is the PR's first coordination pass no matter which event got here. Threads hasExistingComment through from run() (defaulting to true, i.e. additive-fill, on a comment-fetch failure so an API hiccup can't be mistaken for a fresh PR). 2 new tests: the #6479 race itself, and a contrast case confirming a routine review_requested on an already-established PR still uses additive-fill.
1 parent f58909c commit 8e98ae3

2 files changed

Lines changed: 83 additions & 7 deletions

File tree

.github/scripts/review-checklist.js

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -347,21 +347,37 @@ function planSynchronizeSwaps(eligibleAreas, allReviews, {
347347
// signal that someone, right now, individually decided this
348348
// person should be back — see the updatedExcluded comment).
349349
//
350-
// draft, just (re)opened as one
350+
// draft, just (re)opened as one — OR no checklist comment posted yet
351351
// → GitHub's CODEOWNERS auto-assignment fires immediately on
352352
// creation regardless of draft status, dumping every
353353
// touched-area owner onto the PR before anyone's decided
354354
// review is even wanted yet. Cleared in full — no checklist
355355
// posted until the PR is ready for review.
356356
//
357-
// non-draft, just (re)opened or just marked ready_for_review
357+
// non-draft, just (re)opened or just marked ready_for_review —
358+
// OR no checklist comment posted yet
358359
// → Same CODEOWNERS avalanche — GitHub auto-requests CODEOWNERS
359360
// reviewers both on creation and again when a draft is marked
360361
// ready for review. Pruned to the load-balanced single pick
361362
// per area before the checklist is first posted, so reviewers
362363
// aren't @-mentioned en masse before the final reviewer set
363364
// is known.
364365
//
366+
// The "no comment posted yet" clause covers a race: GitHub's
367+
// CODEOWNERS engine fires one review_requested per
368+
// auto-assigned owner, and each re-triggers this workflow.
369+
// With concurrency: cancel-in-progress, whichever run starts
370+
// last wins — and that's just as likely to be one of those
371+
// review_requested runs as the opened run itself (PR #6479
372+
// hit exactly this: a review_requested run survived, fell
373+
// through to the additive-fill branch below, saw every area
374+
// already "covered" by the avalanche, and pruned nothing).
375+
// Whether a checklist comment exists yet is a far more
376+
// reliable signal than which specific action survived the
377+
// race: if none exists, this is the PR's first coordination
378+
// pass no matter what action got here, so the avalanche
379+
// still needs pruning.
380+
//
365381
// synchronize with a dismissed reviewer
366382
// → see planSynchronizeSwaps: re-requests a reviewer whose
367383
// approval a new push just dismissed, swapping out a fresh
@@ -393,10 +409,17 @@ function planSynchronizeSwaps(eligibleAreas, allReviews, {
393409
//
394410
async function coordinateReviewers(github, context, core, {
395411
owner, repo, pr_number, prData, allCodeOwners, catchAllOwners, touchedAreas, reviewerLoad,
396-
excludedReviewers, allReviews, LINE_THRESHOLD, AREA_THRESHOLDS, PUBLIC_HEADER,
412+
excludedReviewers, allReviews, hasExistingComment, LINE_THRESHOLD, AREA_THRESHOLDS, PUBLIC_HEADER,
397413
}) {
398414
const pr = { owner, repo, pr_number };
399415
const action = context.payload.action;
416+
// No checklist comment yet means this is this PR's first coordination
417+
// pass, regardless of which webhook action's run happened to survive the
418+
// opened-vs-review_requested cancel-in-progress race — see coordinateReviewers
419+
// doc comment above. Require an explicit `false` so a caller that omits the
420+
// field (or a stale/odd payload) defaults to the safer additive-fill path
421+
// instead of unexpectedly pruning an established PR's reviewers.
422+
const isFirstCoordinationPass = hasExistingComment === false;
400423

401424
// A removal happening right now joins the persisted exclusion set
402425
// immediately, so it's enforced starting with this very run. A direct
@@ -473,7 +496,7 @@ async function coordinateReviewers(github, context, core, {
473496
const touchedAreaOwners = new Set([...touchedAreas.flatMap(a => a.owners), ...catchAllOwners]);
474497

475498
if (isDraft) {
476-
if (action === 'opened' || action === 'reopened') {
499+
if (action === 'opened' || action === 'reopened' || isFirstCoordinationPass) {
477500
// (Re)opened directly as a draft — clear the CODEOWNERS avalanche from
478501
// this PR's creation. Owners of areas this PR actually touches, plus
479502
// catch-all "*" owners (who GitHub auto-assigns on every PR regardless
@@ -499,13 +522,17 @@ async function coordinateReviewers(github, context, core, {
499522
owners: area.owners.filter(o => !updatedExcluded.has(o)),
500523
}));
501524

502-
if (action === 'opened' || action === 'reopened' || action === 'ready_for_review') {
525+
if (action === 'opened' || action === 'reopened' || action === 'ready_for_review' || isFirstCoordinationPass) {
503526
// Non-draft, just (re)opened — same CODEOWNERS avalanche problem as the
504527
// draft case: GitHub has already auto-assigned every touched-area owner.
505528
// ready_for_review gets the same treatment: GitHub auto-requests CODEOWNERS
506529
// reviewers again when a draft is marked ready, dumping the avalanche on a
507530
// PR that may have sat in draft (untouched, per the isDraft branch above)
508-
// for a while. Prune to a load-balanced single pick per area BEFORE posting
531+
// for a while. isFirstCoordinationPass catches the case where neither of
532+
// those actions is the one that happened to survive the cancel-in-progress
533+
// race against the avalanche's own review_requested events (see the doc
534+
// comment above coordinateReviewers — this is exactly what happened on
535+
// PR #6479). Prune to a load-balanced single pick per area BEFORE posting
509536
// the checklist so reviewers aren't @-mentioned en masse. Pass an empty
510537
// existingRequested so chooseReviewers treats every area as uncovered and
511538
// picks fresh rather than seeing "already has an owner" and returning nothing.
@@ -767,19 +794,26 @@ module.exports = async function run({ github, context, core }) {
767794
// (if any), then coordinate reviewer assignment.
768795
// ----------------------------------------------------------------
769796
let existingComment;
797+
let commentFetchFailed = false;
770798
try {
771799
const comments = await github.paginate(github.rest.issues.listComments, {
772800
owner, repo, issue_number: pr_number, per_page: 100,
773801
});
774802
existingComment = comments.find(c => c.body.includes(MARKER));
775803
} catch (error) {
776804
core.warning(`Could not fetch existing checklist comment: ${error.message}`);
805+
commentFetchFailed = true;
777806
}
778807
const excludedReviewers = parseExcluded(existingComment && existingComment.body);
808+
// On a fetch failure we genuinely don't know whether a comment exists —
809+
// default to true (assume it does) so coordinateReviewers falls back to its
810+
// non-destructive additive-fill path rather than treating an API hiccup as
811+
// "first coordination pass" and pruning an established PR's reviewers.
812+
const hasExistingComment = commentFetchFailed ? true : !!existingComment;
779813

780814
const { confirmedRequested, excludedReviewers: updatedExcluded } = await coordinateReviewers(github, context, core, {
781815
owner, repo, pr_number, prData, allCodeOwners, catchAllOwners, touchedAreas, reviewerLoad,
782-
excludedReviewers, allReviews, LINE_THRESHOLD, AREA_THRESHOLDS, PUBLIC_HEADER,
816+
excludedReviewers, allReviews, hasExistingComment, LINE_THRESHOLD, AREA_THRESHOLDS, PUBLIC_HEADER,
783817
});
784818

785819
// ----------------------------------------------------------------

.github/scripts/review-checklist.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,9 @@ function makeCoordinateBaseArgs(overrides) {
731731
reviewerLoad: {},
732732
excludedReviewers: new Set(),
733733
allReviews: [],
734+
// Most scenarios model an already-established PR (checklist already
735+
// posted); tests exercising the fresh-PR race override this explicitly.
736+
hasExistingComment: true,
734737
LINE_THRESHOLD: 300,
735738
AREA_THRESHOLDS: {},
736739
PUBLIC_HEADER: /public\.h$/,
@@ -785,6 +788,45 @@ asyncTest('coordinateReviewers: plain synchronize (no dismissed reviews) is left
785788
assert.deepStrictEqual([...confirmedRequested].sort(), ['glennsong09', 'hyoklee', 'jhendersonHDF']);
786789
});
787790

791+
asyncTest('coordinateReviewers: review_requested survives the opened race and still prunes (PR #6479 scenario)', async () => {
792+
// GitHub's CODEOWNERS engine fires one review_requested per auto-assigned
793+
// owner; each re-triggers this workflow, and concurrency: cancel-in-progress
794+
// means any of those runs — not necessarily the "opened" run — can be the
795+
// one that actually executes. hasExistingComment: false (no checklist
796+
// posted yet) is what lets a surviving review_requested run still prune
797+
// the avalanche instead of falling through to additive-fill and keeping
798+
// all three.
799+
const github = makeGithubMock();
800+
const context = {
801+
eventName: 'pull_request_target',
802+
payload: { action: 'review_requested', requested_reviewer: { login: 'jhendersonHDF' }, sender: { type: 'User' } },
803+
};
804+
const args = makeCoordinateBaseArgs({ hasExistingComment: false });
805+
806+
const { confirmedRequested } = await coordinateReviewers(github, context, makeCore(), args);
807+
808+
assert.strictEqual(confirmedRequested.size, 1);
809+
assert.ok(github.calls.removeRequestedReviewers.length > 0);
810+
});
811+
812+
asyncTest('coordinateReviewers: review_requested on an already-established PR is NOT treated as a fresh-PR prune', async () => {
813+
// Contrast case: once a checklist comment exists, a routine review_requested
814+
// later in the PR's life (e.g. a human manually adding a reviewer) must stay
815+
// on the additive-fill path — it must not be reinterpreted as "first
816+
// coordination pass" and prune reviewers an established PR already has.
817+
const github = makeGithubMock();
818+
const context = {
819+
eventName: 'pull_request_target',
820+
payload: { action: 'review_requested', requested_reviewer: { login: 'jhendersonHDF' }, sender: { type: 'User' } },
821+
};
822+
const args = makeCoordinateBaseArgs({ hasExistingComment: true });
823+
824+
const { confirmedRequested } = await coordinateReviewers(github, context, makeCore(), args);
825+
826+
assert.strictEqual(github.calls.removeRequestedReviewers.length, 0);
827+
assert.deepStrictEqual([...confirmedRequested].sort(), ['glennsong09', 'hyoklee', 'jhendersonHDF']);
828+
});
829+
788830
// ----------------------------------------------------------------
789831
// coordinateReviewers — bot-self-triggered review_request_removed must not
790832
// create a sticky exclusion (the bot's own removeUnselected/removeRequestedReviewers

0 commit comments

Comments
 (0)