@@ -34,33 +34,138 @@ permissions:
3434 checks : read
3535 statuses : write
3636
37- # One concurrency group per PR so bursts of activity on unrelated PRs cannot
38- # starve an individual PR's reconciliation (issue #1707). GitHub keeps only the
39- # newest pending run per group; keyed per PR that becomes freshest-state-wins
40- # within a PR. Issue/PR numbers share one GitHub sequence, so issue-comment runs
41- # can never collide with a different PR's group. Sweep triggers (hourly
42- # schedule, pushes to main) share a single group, and events that cannot be
43- # tied to a PR (fork workflow_run without backfilled pull_requests) fall back
44- # to a unique per-run group so they are never superseded.
37+ # Concurrency is keyed at two levels (issue #1707):
38+ #
39+ # Workflow level (below): keeps bursts of activity on unrelated PRs from
40+ # starving whole runs. GitHub keeps only the newest pending run per group even
41+ # with cancel-in-progress: false, so keying per PR makes that freshest-state-
42+ # wins within one PR. Issue/PR numbers share one GitHub sequence, so
43+ # issue-comment runs can never collide with a different PR's group.
44+ # workflow_run events key by the triggering run's id instead of a PR number: a
45+ # single run can reconcile several associated PRs (and fork runs backfill
46+ # none), so a PR-number key would let an unrelated event for the first PR
47+ # supersede reconciliation of the rest. Sweep triggers (hourly schedule,
48+ # pushes to main) share one group, and anything unresolved falls back to a
49+ # unique per-run group.
50+ #
51+ # Job level (on the reconcile job): every mutating shard keys per PR in one
52+ # shared group namespace, so direct-event shards, sweep-resolved shards, and
53+ # workflow_run-resolved shards for the same PR serialize against each other
54+ # instead of racing non-atomic label, status, and comment writes. Only the
55+ # reconcile job mutates PR state, so no workflow-level key can bypass that
56+ # per-PR serialization.
4557concurrency :
4658 group : >-
4759 label-pr-review-state-${{
4860 github.event.pull_request.number ||
4961 github.event.issue.number ||
50- github.event.workflow_run.pull_requests[0].number ||
5162 github.event.inputs.pull_request_number ||
63+ github.event.workflow_run.id ||
5264 ((github.event_name == 'schedule' || github.event_name == 'push') && 'sweep') ||
5365 github.run_id
5466 }}
5567 cancel-in-progress : false
5668
5769jobs :
58- reconcile :
59- name : Zoo Code / reconcile PR review state
70+ resolve :
71+ name : Zoo Code / resolve PRs to reconcile
6072 if : >-
6173 github.event_name != 'issue_comment' ||
6274 (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')
6375 runs-on : ubuntu-latest
76+ outputs :
77+ pr_numbers : ${{ steps.resolve.outputs.pr_numbers }}
78+ steps :
79+ - name : Resolve target PR numbers
80+ id : resolve
81+ uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
82+ with :
83+ retries : 3
84+ script : |
85+ const { owner, repo } = context.repo;
86+
87+ if (context.eventName === 'issue_comment' &&
88+ (!context.payload.issue?.pull_request ||
89+ context.payload.comment?.user?.login?.toLowerCase() !== 'coderabbitai[bot]')) {
90+ core.info('Ignoring untrusted issue comment event');
91+ core.setOutput('pr_numbers', '[]');
92+ return;
93+ }
94+
95+ // Resolve which PRs this event targets. Single-PR events carry the
96+ // number in the payload; sweep triggers (hourly schedule, pushes to
97+ // main) list every open PR; workflow_run events use the run's
98+ // associated PRs, with a fork-safe backfill by exact head owner,
99+ // branch, and SHA when GitHub left pull_requests empty.
100+ let prNumbers = [];
101+ if (context.payload.pull_request?.number) {
102+ prNumbers = [context.payload.pull_request.number];
103+ } else if (context.eventName === 'issue_comment') {
104+ prNumbers = [context.payload.issue.number];
105+ } else if (context.eventName === 'workflow_dispatch') {
106+ prNumbers = [Number(context.payload.inputs.pull_request_number)];
107+ } else if (context.payload.workflow_run?.pull_requests?.length > 0) {
108+ prNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number);
109+ } else if (context.eventName === 'workflow_run') {
110+ const run = context.payload.workflow_run;
111+ const headOwner = run.head_repository?.owner?.login;
112+ if (headOwner && run.head_branch && run.head_sha) {
113+ const candidates = await github.paginate(github.rest.pulls.list, {
114+ owner,
115+ repo,
116+ state: 'open',
117+ head: `${headOwner}:${run.head_branch}`,
118+ per_page: 100,
119+ });
120+ const baseRepository = `${owner}/${repo}`.toLowerCase();
121+ prNumbers = candidates
122+ .filter(pr =>
123+ pr.head?.sha === run.head_sha &&
124+ pr.base?.repo?.full_name?.toLowerCase() === baseRepository
125+ )
126+ .map(pr => pr.number);
127+ core.info(
128+ `Resolved workflow_run ${run.id ?? '(unknown)'} to ` +
129+ `${prNumbers.length} PR(s) by exact head owner, branch, and SHA`
130+ );
131+ }
132+ } else {
133+ const openPrs = await github.paginate(github.rest.pulls.list, {
134+ owner, repo, state: 'open', per_page: 100,
135+ });
136+ prNumbers = openPrs.map(pr => pr.number);
137+ }
138+
139+ core.info(
140+ `Reconciliation targets: ` +
141+ `${prNumbers.length > 0 ? prNumbers.join(', ') : '(none)'}`
142+ );
143+ core.setOutput('pr_numbers', JSON.stringify(prNumbers));
144+
145+ reconcile :
146+ name : Zoo Code / reconcile PR review state
147+ needs : resolve
148+ if : >-
149+ (github.event_name != 'issue_comment' ||
150+ (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) &&
151+ needs.resolve.outputs.pr_numbers != '[]'
152+ strategy :
153+ fail-fast : false
154+ matrix :
155+ pr_number : ${{ fromJSON(needs.resolve.outputs.pr_numbers) }}
156+ # One mutating shard per PR, serialized per PR across every trigger path:
157+ # direct-event shards, sweep-resolved shards, and workflow_run-resolved
158+ # shards all share this group namespace, so their non-atomic writes can
159+ # never interleave. cancel-in-progress stays false — within one PR,
160+ # newest-pending-wins is exactly freshest-state-wins. The run_id fallback
161+ # only applies to the skipped empty-resolution case so empty runs never
162+ # contend.
163+ concurrency :
164+ group : label-pr-review-state-${{ matrix.pr_number || github.run_id }}
165+ cancel-in-progress : false
166+ runs-on : ubuntu-latest
167+ env :
168+ PR_NUMBER : ${{ matrix.pr_number }}
64169 steps :
65170 - name : Reconcile PR review state labels
66171 uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -114,56 +219,15 @@ jobs:
114219 return;
115220 }
116221
117- // When triggered by a single PR event, only reconcile that PR.
118- // Main-branch pushes, the hourly schedule, and workflow_dispatch reconcile all open PRs.
119- let prs;
120- let eventPrNumbers = [];
121- if (context.payload.pull_request?.number) {
122- eventPrNumbers = [context.payload.pull_request.number];
123- } else if (context.eventName === 'issue_comment') {
124- eventPrNumbers = [context.payload.issue.number];
125- } else if (context.eventName === 'workflow_dispatch') {
126- eventPrNumbers = [Number(context.payload.inputs.pull_request_number)];
127- } else if (context.payload.workflow_run?.pull_requests) {
128- eventPrNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number);
129- }
130- if (context.eventName === 'workflow_run' && eventPrNumbers.length === 0) {
131- const run = context.payload.workflow_run;
132- const headOwner = run.head_repository?.owner?.login;
133- if (headOwner && run.head_branch && run.head_sha) {
134- const candidates = await github.paginate(github.rest.pulls.list, {
135- owner,
136- repo,
137- state: 'open',
138- head: `${headOwner}:${run.head_branch}`,
139- per_page: 100,
140- });
141- const baseRepository = `${owner}/${repo}`.toLowerCase();
142- eventPrNumbers = candidates
143- .filter(pr =>
144- pr.head?.sha === run.head_sha &&
145- pr.base?.repo?.full_name?.toLowerCase() === baseRepository
146- )
147- .map(pr => pr.number);
148- core.info(
149- `Resolved workflow_run ${run.id ?? '(unknown)'} to ` +
150- `${eventPrNumbers.length} PR(s) by exact head owner, branch, and SHA`
151- );
152- }
153- }
154- if (eventPrNumbers.length > 0) {
155- prs = await Promise.all(eventPrNumbers.map(async pull_number => {
156- const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
157- return pr;
158- }));
159- prs = prs.filter(pr => pr.state === 'open');
160- } else if (context.eventName === 'workflow_run') {
161- prs = [];
162- } else {
163- prs = await github.paginate(github.rest.pulls.list, {
164- owner, repo, state: 'open', per_page: 100,
165- });
166- }
222+ // The resolve job fans out one shard per target PR; this job
223+ // reconciles exactly this matrix shard's PR. The single-PR GET
224+ // endpoint returns mergeable fields that list endpoints omit.
225+ const { data: shardPr } = await github.rest.pulls.get({
226+ owner,
227+ repo,
228+ pull_number: Number(process.env.PR_NUMBER),
229+ });
230+ const prs = shardPr.state === 'open' ? [shardPr] : [];
167231
168232 // pull_request_review runs originating from forks can still receive a read-only
169233 // token. pull_request_target, schedule, and workflow_dispatch runs execute in the
@@ -184,7 +248,12 @@ jobs:
184248 await github.rest.issues.getLabel({ owner, repo, name: label.name });
185249 } catch (error) {
186250 if (error.status !== 404) throw error;
187- await github.rest.issues.createLabel({ owner, repo, ...label });
251+ try {
252+ await github.rest.issues.createLabel({ owner, repo, ...label });
253+ } catch (createError) {
254+ // Concurrent shards for other PRs can win the creation race.
255+ if (createError.status !== 422) throw createError;
256+ }
188257 }
189258 }
190259 }
@@ -500,8 +569,13 @@ jobs:
500569 for (const pr of prs) {
501570 let failurePhase = 'ci-pending';
502571 try {
572+ // The matrix job reports one check per shard named
573+ // "<reconciliationCheckName> (<pr_number>)", so catch both the
574+ // bare name and any matrix-suffixed variant.
503575 const selfReferentialRequirements = (requiredChecks ?? []).filter(check =>
504- check.context === reviewGateName || check.context === reconciliationCheckName
576+ check.context === reviewGateName ||
577+ check.context === reconciliationCheckName ||
578+ check.context.startsWith(`${reconciliationCheckName} (`)
505579 );
506580 if (selfReferentialRequirements.length > 0) {
507581 failurePhase = 'configuration-error';
@@ -519,15 +593,12 @@ jobs:
519593
520594 let existingGuide = await findReviewGuide(pr);
521595
522- // `mergeable`/`mergeable_state` are only returned by the single-PR GET
523- // endpoint, and are computed asynchronously by GitHub — a PR fetched via
524- // pulls.list (schedule/workflow_dispatch runs) never has them, and even a
525- // single-PR fetch can return `null`/"unknown" if the merge check hasn't
526- // finished yet. Re-fetch the single PR to get a fresh value, and treat
527- // "unknown" as not-yet-computed rather than as conflicting.
528- const prDetail = eventPrNumbers.length > 0
529- ? pr
530- : (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data;
596+ // `mergeable`/`mergeable_state` are only returned by the single-PR
597+ // GET endpoint and are computed asynchronously by GitHub — this
598+ // shard's own pulls.get above provides them, but even that can
599+ // return `null`/"unknown" while the merge check is still running.
600+ // Treat "unknown" as not-yet-computed rather than as conflicting.
601+ const prDetail = pr;
531602
532603 if (prDetail.mergeable === false && prDetail.mergeable_state === 'dirty') {
533604 core.info(`PR #${pr.number}: has merge conflicts — labeling has-conflicts`);
@@ -702,12 +773,14 @@ jobs:
702773 }
703774 }
704775
705- // Clear blockers the author explicitly re-requested. Only a
706- // review_requested timeline event whose actor is the PR author and
707- // whose requested reviewer is the blocking maintainer clears that
708- // maintainer's blocker. Team requests carry no requested_reviewer
709- // and never clear an individual blocker; review_request_removed
710- // events only trigger reconciliation and are not clearing evidence.
776+ // Clear blockers that were explicitly re-requested. A
777+ // review_requested timeline event clears that maintainer's
778+ // blocker when the actor is the PR author re-requesting the
779+ // blocking maintainer, or the blocking maintainer re-requesting
780+ // themselves (signaling intent to re-review). Team requests
781+ // carry no requested_reviewer and never clear an individual
782+ // blocker; review_request_removed events only trigger
783+ // reconciliation and are not clearing evidence.
711784 // If the timeline cannot be reconstructed, fail closed: keep every
712785 // blocker so awaiting-author is preserved.
713786 if (maintainerBlockers.size > 0) {
0 commit comments