@@ -45,33 +45,138 @@ permissions:
4545 checks : read
4646 statuses : write
4747
48- # One concurrency group per PR so bursts of activity on unrelated PRs cannot
49- # starve an individual PR's reconciliation (issue #1707). GitHub keeps only the
50- # newest pending run per group; keyed per PR that becomes freshest-state-wins
51- # within a PR. Issue/PR numbers share one GitHub sequence, so issue-comment runs
52- # can never collide with a different PR's group. Sweep triggers (hourly
53- # schedule, pushes to main) share a single group, and events that cannot be
54- # tied to a PR (fork workflow_run without backfilled pull_requests) fall back
55- # to a unique per-run group so they are never superseded.
48+ # Concurrency is keyed at two levels (issue #1707):
49+ #
50+ # Workflow level (below): keeps bursts of activity on unrelated PRs from
51+ # starving whole runs. GitHub keeps only the newest pending run per group even
52+ # with cancel-in-progress: false, so keying per PR makes that freshest-state-
53+ # wins within one PR. Issue/PR numbers share one GitHub sequence, so
54+ # issue-comment runs can never collide with a different PR's group.
55+ # workflow_run events key by the triggering run's id instead of a PR number: a
56+ # single run can reconcile several associated PRs (and fork runs backfill
57+ # none), so a PR-number key would let an unrelated event for the first PR
58+ # supersede reconciliation of the rest. Sweep triggers (hourly schedule,
59+ # pushes to main) share one group, and anything unresolved falls back to a
60+ # unique per-run group.
61+ #
62+ # Job level (on the reconcile job): every mutating shard keys per PR in one
63+ # shared group namespace, so direct-event shards, sweep-resolved shards, and
64+ # workflow_run-resolved shards for the same PR serialize against each other
65+ # instead of racing non-atomic label, status, and comment writes. Only the
66+ # reconcile job mutates PR state, so no workflow-level key can bypass that
67+ # per-PR serialization.
5668concurrency :
5769 group : >-
5870 label-pr-review-state-${{
5971 github.event.pull_request.number ||
6072 github.event.issue.number ||
61- github.event.workflow_run.pull_requests[0].number ||
6273 github.event.inputs.pull_request_number ||
74+ github.event.workflow_run.id ||
6375 ((github.event_name == 'schedule' || github.event_name == 'push') && 'sweep') ||
6476 github.run_id
6577 }}
6678 cancel-in-progress : false
6779
6880jobs :
69- reconcile :
70- name : Zoo Code / reconcile PR review state
81+ resolve :
82+ name : Zoo Code / resolve PRs to reconcile
7183 if : >-
7284 github.event_name != 'issue_comment' ||
7385 (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')
7486 runs-on : ubuntu-latest
87+ outputs :
88+ pr_numbers : ${{ steps.resolve.outputs.pr_numbers }}
89+ steps :
90+ - name : Resolve target PR numbers
91+ id : resolve
92+ uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
93+ with :
94+ retries : 3
95+ script : |
96+ const { owner, repo } = context.repo;
97+
98+ if (context.eventName === 'issue_comment' &&
99+ (!context.payload.issue?.pull_request ||
100+ context.payload.comment?.user?.login?.toLowerCase() !== 'coderabbitai[bot]')) {
101+ core.info('Ignoring untrusted issue comment event');
102+ core.setOutput('pr_numbers', '[]');
103+ return;
104+ }
105+
106+ // Resolve which PRs this event targets. Single-PR events carry the
107+ // number in the payload; sweep triggers (hourly schedule, pushes to
108+ // main) list every open PR; workflow_run events use the run's
109+ // associated PRs, with a fork-safe backfill by exact head owner,
110+ // branch, and SHA when GitHub left pull_requests empty.
111+ let prNumbers = [];
112+ if (context.payload.pull_request?.number) {
113+ prNumbers = [context.payload.pull_request.number];
114+ } else if (context.eventName === 'issue_comment') {
115+ prNumbers = [context.payload.issue.number];
116+ } else if (context.eventName === 'workflow_dispatch') {
117+ prNumbers = [Number(context.payload.inputs.pull_request_number)];
118+ } else if (context.payload.workflow_run?.pull_requests?.length > 0) {
119+ prNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number);
120+ } else if (context.eventName === 'workflow_run') {
121+ const run = context.payload.workflow_run;
122+ const headOwner = run.head_repository?.owner?.login;
123+ if (headOwner && run.head_branch && run.head_sha) {
124+ const candidates = await github.paginate(github.rest.pulls.list, {
125+ owner,
126+ repo,
127+ state: 'open',
128+ head: `${headOwner}:${run.head_branch}`,
129+ per_page: 100,
130+ });
131+ const baseRepository = `${owner}/${repo}`.toLowerCase();
132+ prNumbers = candidates
133+ .filter(pr =>
134+ pr.head?.sha === run.head_sha &&
135+ pr.base?.repo?.full_name?.toLowerCase() === baseRepository
136+ )
137+ .map(pr => pr.number);
138+ core.info(
139+ `Resolved workflow_run ${run.id ?? '(unknown)'} to ` +
140+ `${prNumbers.length} PR(s) by exact head owner, branch, and SHA`
141+ );
142+ }
143+ } else {
144+ const openPrs = await github.paginate(github.rest.pulls.list, {
145+ owner, repo, state: 'open', per_page: 100,
146+ });
147+ prNumbers = openPrs.map(pr => pr.number);
148+ }
149+
150+ core.info(
151+ `Reconciliation targets: ` +
152+ `${prNumbers.length > 0 ? prNumbers.join(', ') : '(none)'}`
153+ );
154+ core.setOutput('pr_numbers', JSON.stringify(prNumbers));
155+
156+ reconcile :
157+ name : Zoo Code / reconcile PR review state
158+ needs : resolve
159+ if : >-
160+ (github.event_name != 'issue_comment' ||
161+ (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) &&
162+ needs.resolve.outputs.pr_numbers != '[]'
163+ strategy :
164+ fail-fast : false
165+ matrix :
166+ pr_number : ${{ fromJSON(needs.resolve.outputs.pr_numbers) }}
167+ # One mutating shard per PR, serialized per PR across every trigger path:
168+ # direct-event shards, sweep-resolved shards, and workflow_run-resolved
169+ # shards all share this group namespace, so their non-atomic writes can
170+ # never interleave. cancel-in-progress stays false — within one PR,
171+ # newest-pending-wins is exactly freshest-state-wins. The run_id fallback
172+ # only applies to the skipped empty-resolution case so empty runs never
173+ # contend.
174+ concurrency :
175+ group : label-pr-review-state-${{ matrix.pr_number || github.run_id }}
176+ cancel-in-progress : false
177+ runs-on : ubuntu-latest
178+ env :
179+ PR_NUMBER : ${{ matrix.pr_number }}
75180 steps :
76181 - name : Reconcile PR review state labels
77182 uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -133,56 +238,15 @@ jobs:
133238 return;
134239 }
135240
136- // When triggered by a single PR event, only reconcile that PR.
137- // Main-branch pushes, the hourly schedule, and workflow_dispatch reconcile all open PRs.
138- let prs;
139- let eventPrNumbers = [];
140- if (context.payload.pull_request?.number) {
141- eventPrNumbers = [context.payload.pull_request.number];
142- } else if (context.eventName === 'issue_comment') {
143- eventPrNumbers = [context.payload.issue.number];
144- } else if (context.eventName === 'workflow_dispatch') {
145- eventPrNumbers = [Number(context.payload.inputs.pull_request_number)];
146- } else if (context.payload.workflow_run?.pull_requests) {
147- eventPrNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number);
148- }
149- if (context.eventName === 'workflow_run' && eventPrNumbers.length === 0) {
150- const run = context.payload.workflow_run;
151- const headOwner = run.head_repository?.owner?.login;
152- if (headOwner && run.head_branch && run.head_sha) {
153- const candidates = await github.paginate(github.rest.pulls.list, {
154- owner,
155- repo,
156- state: 'open',
157- head: `${headOwner}:${run.head_branch}`,
158- per_page: 100,
159- });
160- const baseRepository = `${owner}/${repo}`.toLowerCase();
161- eventPrNumbers = candidates
162- .filter(pr =>
163- pr.head?.sha === run.head_sha &&
164- pr.base?.repo?.full_name?.toLowerCase() === baseRepository
165- )
166- .map(pr => pr.number);
167- core.info(
168- `Resolved workflow_run ${run.id ?? '(unknown)'} to ` +
169- `${eventPrNumbers.length} PR(s) by exact head owner, branch, and SHA`
170- );
171- }
172- }
173- if (eventPrNumbers.length > 0) {
174- prs = await Promise.all(eventPrNumbers.map(async pull_number => {
175- const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
176- return pr;
177- }));
178- prs = prs.filter(pr => pr.state === 'open');
179- } else if (context.eventName === 'workflow_run') {
180- prs = [];
181- } else {
182- prs = await github.paginate(github.rest.pulls.list, {
183- owner, repo, state: 'open', per_page: 100,
184- });
185- }
241+ // The resolve job fans out one shard per target PR; this job
242+ // reconciles exactly this matrix shard's PR. The single-PR GET
243+ // endpoint returns mergeable fields that list endpoints omit.
244+ const { data: shardPr } = await github.rest.pulls.get({
245+ owner,
246+ repo,
247+ pull_number: Number(process.env.PR_NUMBER),
248+ });
249+ const prs = shardPr.state === 'open' ? [shardPr] : [];
186250
187251 // pull_request_review runs originating from forks can still receive a read-only
188252 // token. pull_request_target, schedule, and workflow_dispatch runs execute in the
@@ -203,7 +267,12 @@ jobs:
203267 await github.rest.issues.getLabel({ owner, repo, name: label.name });
204268 } catch (error) {
205269 if (error.status !== 404) throw error;
206- await github.rest.issues.createLabel({ owner, repo, ...label });
270+ try {
271+ await github.rest.issues.createLabel({ owner, repo, ...label });
272+ } catch (createError) {
273+ // Concurrent shards for other PRs can win the creation race.
274+ if (createError.status !== 422) throw createError;
275+ }
207276 }
208277 }
209278 }
@@ -564,8 +633,13 @@ jobs:
564633 for (const pr of prs) {
565634 let failurePhase = 'ci-pending';
566635 try {
636+ // The matrix job reports one check per shard named
637+ // "<reconciliationCheckName> (<pr_number>)", so catch both the
638+ // bare name and any matrix-suffixed variant.
567639 const selfReferentialRequirements = (requiredChecks ?? []).filter(check =>
568- check.context === reviewGateName || check.context === reconciliationCheckName
640+ check.context === reviewGateName ||
641+ check.context === reconciliationCheckName ||
642+ check.context.startsWith(`${reconciliationCheckName} (`)
569643 );
570644 if (selfReferentialRequirements.length > 0) {
571645 failurePhase = 'configuration-error';
@@ -584,15 +658,12 @@ jobs:
584658
585659 let existingGuide = await findReviewGuide(pr);
586660
587- // `mergeable`/`mergeable_state` are only returned by the single-PR GET
588- // endpoint, and are computed asynchronously by GitHub — a PR fetched via
589- // pulls.list (schedule/workflow_dispatch runs) never has them, and even a
590- // single-PR fetch can return `null`/"unknown" if the merge check hasn't
591- // finished yet. Re-fetch the single PR to get a fresh value, and treat
592- // "unknown" as not-yet-computed rather than as conflicting.
593- const prDetail = eventPrNumbers.length > 0
594- ? pr
595- : (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data;
661+ // `mergeable`/`mergeable_state` are only returned by the single-PR
662+ // GET endpoint and are computed asynchronously by GitHub — this
663+ // shard's own pulls.get above provides them, but even that can
664+ // return `null`/"unknown" while the merge check is still running.
665+ // Treat "unknown" as not-yet-computed rather than as conflicting.
666+ const prDetail = pr;
596667
597668 if (prDetail.mergeable === false && prDetail.mergeable_state === 'dirty') {
598669 core.info(`PR #${pr.number}: has merge conflicts — labeling has-conflicts`);
@@ -772,12 +843,14 @@ jobs:
772843 }
773844 }
774845
775- // Clear blockers the author explicitly re-requested. Only a
776- // review_requested timeline event whose actor is the PR author and
777- // whose requested reviewer is the blocking maintainer clears that
778- // maintainer's blocker. Team requests carry no requested_reviewer
779- // and never clear an individual blocker; review_request_removed
780- // events only trigger reconciliation and are not clearing evidence.
846+ // Clear blockers that were explicitly re-requested. A
847+ // review_requested timeline event clears that maintainer's
848+ // blocker when the actor is the PR author re-requesting the
849+ // blocking maintainer, or the blocking maintainer re-requesting
850+ // themselves (signaling intent to re-review). Team requests
851+ // carry no requested_reviewer and never clear an individual
852+ // blocker; review_request_removed events only trigger
853+ // reconciliation and are not clearing evidence.
781854 // If the timeline cannot be reconstructed, fail closed: keep every
782855 // blocker so awaiting-author is preserved.
783856 if (maintainerBlockers.size > 0) {
0 commit comments