@@ -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//
394410async 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 // ----------------------------------------------------------------
0 commit comments