Repository navigation
feat(tools): wire guarded writes into the diff-view save paths (S4b, #1375) #62585
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Label PR review state | ||
|
Check warning on line 1 in .github/workflows/label-pr-review-state.yml
|
||
| on: | ||
| schedule: | ||
| - cron: "0 * * * *" # hourly fallback | ||
| push: | ||
| branches: [main] | ||
| workflow_dispatch: | ||
| inputs: | ||
| pull_request_number: | ||
| description: Pull request number to reconcile | ||
| required: true | ||
| type: number | ||
| # This workflow only reads PR metadata and never checks out or executes PR code. | ||
| # pull_request_target gives fork PRs a token that can update labels and comments. | ||
| pull_request_target: | ||
| types: | ||
| [ | ||
| opened, | ||
| reopened, | ||
| ready_for_review, | ||
| converted_to_draft, | ||
| synchronize, | ||
| review_requested, | ||
| review_request_removed, | ||
| labeled, | ||
| unlabeled, | ||
| ] | ||
| pull_request_review: | ||
| types: [submitted, dismissed] | ||
| # Fork review events have a read-only token. CodeRabbit's status-comment update | ||
| # provides a trusted base-repository event that can reconcile those PRs promptly. | ||
| issue_comment: | ||
| types: [created, edited] | ||
| workflow_run: | ||
| workflows: ["Code QA Roo Code", "E2E Tests (Mocked)", "Webview Visual Regression", "CodeQL Advanced"] | ||
| types: [completed] | ||
| permissions: | ||
| # This privileged workflow only reads PR/check metadata and writes issue labels, | ||
| # comments, and commit statuses. All unspecified permissions, including contents, | ||
| # are none; no fork code or configuration is checked out or executed. | ||
| pull-requests: write | ||
| issues: write | ||
| checks: read | ||
| statuses: write | ||
| # Concurrency is keyed at two levels (issue #1707): | ||
| # | ||
| # Workflow level (below): keeps bursts of activity on unrelated PRs from | ||
| # starving whole runs. GitHub keeps only the newest pending run per group even | ||
| # with cancel-in-progress: false, so keying per PR makes that freshest-state- | ||
| # wins within one PR. Issue/PR numbers share one GitHub sequence, so | ||
| # issue-comment runs can never collide with a different PR's group. | ||
| # workflow_run events key by the triggering run's id instead of a PR number: a | ||
| # single run can reconcile several associated PRs (and fork runs backfill | ||
| # none), so a PR-number key would let an unrelated event for the first PR | ||
| # supersede reconciliation of the rest. Fork pull_request_review runs are | ||
| # read-only (see isReadOnlyRun in the reconcile script) and write nothing, so | ||
| # they take a unique per-run group rather than a PR-keyed group whose | ||
| # newest-pending-wins could supersede a queued mutating run for that PR. | ||
| # Sweep triggers (hourly schedule, pushes to main) share one group, and | ||
| # anything unresolved falls back to a unique per-run group. | ||
| # | ||
| # Job level (on the reconcile job): every mutating shard keys per PR in one | ||
| # shared group namespace, so direct-event shards, sweep-resolved shards, and | ||
| # workflow_run-resolved shards for the same PR serialize against each other | ||
| # instead of racing non-atomic label, status, and comment writes. Only the | ||
| # reconcile job mutates PR state, so no workflow-level key can bypass that | ||
| # per-PR serialization. | ||
| concurrency: | ||
| group: >- | ||
| label-pr-review-state-${{ | ||
| ((github.event_name == 'pull_request_review' && | ||
| github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) && github.run_id) || | ||
| github.event.pull_request.number || | ||
| github.event.issue.number || | ||
| github.event.inputs.pull_request_number || | ||
| github.event.workflow_run.id || | ||
| ((github.event_name == 'schedule' || github.event_name == 'push') && 'sweep') || | ||
| github.run_id | ||
| }} | ||
| cancel-in-progress: false | ||
| jobs: | ||
| resolve: | ||
| name: Zoo Code / resolve PRs to reconcile | ||
| if: >- | ||
| github.event_name != 'issue_comment' || | ||
| (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]') | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| pr_numbers: ${{ steps.resolve.outputs.pr_numbers }} | ||
| env: | ||
| # Mirrors the workflow-level concurrency group so the run's queueing | ||
| # identity is visible in the logs (kept in sync by the workflow tests). | ||
| CONCURRENCY_GROUP: >- | ||
| label-pr-review-state-${{ | ||
| ((github.event_name == 'pull_request_review' && | ||
| github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) && github.run_id) || | ||
| github.event.pull_request.number || | ||
| github.event.issue.number || | ||
| github.event.inputs.pull_request_number || | ||
| github.event.workflow_run.id || | ||
| ((github.event_name == 'schedule' || github.event_name == 'push') && 'sweep') || | ||
| github.run_id | ||
| }} | ||
| steps: | ||
| - name: Resolve target PR numbers | ||
| id: resolve | ||
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | ||
| with: | ||
| retries: 3 | ||
| script: | | ||
| const { owner, repo } = context.repo; | ||
| // A queue-superseded run leaves no other trace in the logs. | ||
| core.info(`Concurrency group: ${process.env.CONCURRENCY_GROUP}`); | ||
| if (context.eventName === 'issue_comment' && | ||
| (!context.payload.issue?.pull_request || | ||
| context.payload.comment?.user?.login?.toLowerCase() !== 'coderabbitai[bot]')) { | ||
| core.info('Ignoring untrusted issue comment event'); | ||
| core.setOutput('pr_numbers', '[]'); | ||
| return; | ||
| } | ||
| // Resolve which PRs this event targets. Single-PR events carry the | ||
| // number in the payload; sweep triggers (hourly schedule, pushes to | ||
| // main) list every open PR; workflow_run events use the run's | ||
| // associated PRs, with a fork-safe backfill by exact head owner, | ||
| // branch, and SHA when GitHub left pull_requests empty. | ||
| let prNumbers = []; | ||
| if (context.payload.pull_request?.number) { | ||
| prNumbers = [context.payload.pull_request.number]; | ||
| } else if (context.eventName === 'issue_comment') { | ||
| prNumbers = [context.payload.issue.number]; | ||
| } else if (context.eventName === 'workflow_dispatch') { | ||
| prNumbers = [Number(context.payload.inputs.pull_request_number)]; | ||
| } else if (context.payload.workflow_run?.pull_requests?.length > 0) { | ||
| prNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number); | ||
| } else if (context.eventName === 'workflow_run') { | ||
| const run = context.payload.workflow_run; | ||
| const headOwner = run.head_repository?.owner?.login; | ||
| if (headOwner && run.head_branch && run.head_sha) { | ||
| const candidates = await github.paginate(github.rest.pulls.list, { | ||
| owner, | ||
| repo, | ||
| state: 'open', | ||
| head: `${headOwner}:${run.head_branch}`, | ||
| per_page: 100, | ||
| }); | ||
| const baseRepository = `${owner}/${repo}`.toLowerCase(); | ||
| prNumbers = candidates | ||
| .filter(pr => | ||
| pr.head?.sha === run.head_sha && | ||
| pr.base?.repo?.full_name?.toLowerCase() === baseRepository | ||
| ) | ||
| .map(pr => pr.number); | ||
| core.info( | ||
| `Resolved workflow_run ${run.id ?? '(unknown)'} to ` + | ||
| `${prNumbers.length} PR(s) by exact head owner, branch, and SHA` | ||
| ); | ||
| } | ||
| } else { | ||
| const openPrs = await github.paginate(github.rest.pulls.list, { | ||
| owner, repo, state: 'open', per_page: 100, | ||
| }); | ||
| // GitHub rejects matrices over 256 jobs, so the sweep pages the | ||
| // sorted open-PR list: consecutive runs rotate through | ||
| // fixed-size windows by run number, covering every open PR on a | ||
| // bounded cadence without ever exceeding the limit. | ||
| const allNumbers = openPrs.map(pr => pr.number).sort((a, b) => a - b); | ||
| const sweepPageSize = 200; | ||
| const pageCount = Math.max(1, Math.ceil(allNumbers.length / sweepPageSize)); | ||
| const page = context.runNumber % pageCount; | ||
| prNumbers = allNumbers.slice(page * sweepPageSize, (page + 1) * sweepPageSize); | ||
| if (allNumbers.length > sweepPageSize) { | ||
| core.info( | ||
| `Sweep page ${page + 1}/${pageCount}: reconciling ` + | ||
| `${prNumbers.length} of ${allNumbers.length} open PRs` | ||
| ); | ||
| } | ||
| } | ||
| core.info( | ||
| `Reconciliation targets: ` + | ||
| `${prNumbers.length > 0 ? prNumbers.join(', ') : '(none)'}` | ||
| ); | ||
| core.setOutput('pr_numbers', JSON.stringify(prNumbers)); | ||
| reconcile: | ||
| name: Zoo Code / reconcile PR review state | ||
| needs: resolve | ||
| if: >- | ||
| (github.event_name != 'issue_comment' || | ||
| (github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) && | ||
| needs.resolve.outputs.pr_numbers != '[]' | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| pr_number: ${{ fromJSON(needs.resolve.outputs.pr_numbers) }} | ||
| # One mutating shard per PR, serialized per PR across every trigger path: | ||
| # direct-event shards, sweep-resolved shards, and workflow_run-resolved | ||
| # shards all share this group namespace, so their non-atomic writes can | ||
| # never interleave. cancel-in-progress stays false — within one PR, | ||
| # newest-pending-wins is exactly freshest-state-wins. The run_id fallback | ||
| # only applies to the skipped empty-resolution case so empty runs never | ||
| # contend. The `-reconcile-` infix keeps job-level groups in their own | ||
| # namespace: concurrency groups are one repo-wide namespace across | ||
| # workflow and job levels, so a shard group equal to the workflow-level | ||
| # group its own run holds (e.g. label-pr-review-state-<pr> for direct | ||
| # triggers, or a workflow_run.id that numerically matches a PR number) | ||
| # fails the job at startup. Read-only fork pull_request_review shards | ||
| # write nothing, so they take a run-scoped `-ro-<run_id>` group instead of | ||
| # the PR-keyed group: the non-numeric `-ro-` infix keeps them from ever | ||
| # colliding with (or superseding) a mutating shard for the same PR. | ||
| concurrency: | ||
| group: >- | ||
| label-pr-review-state-reconcile-${{ | ||
| ((github.event_name == 'pull_request_review' && | ||
| github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) && 'ro-') || '' | ||
| }}${{ | ||
| ((github.event_name == 'pull_request_review' && | ||
| github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) && github.run_id) || | ||
| matrix.pr_number || | ||
| github.run_id | ||
| }} | ||
| cancel-in-progress: false | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| PR_NUMBER: ${{ matrix.pr_number }} | ||
| # Mirrors this job's concurrency group so a superseded shard's queueing | ||
| # identity is visible in the logs (kept in sync by the workflow tests). | ||
| CONCURRENCY_GROUP: >- | ||
| label-pr-review-state-reconcile-${{ | ||
| ((github.event_name == 'pull_request_review' && | ||
| github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) && 'ro-') || '' | ||
| }}${{ | ||
| ((github.event_name == 'pull_request_review' && | ||
| github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) && github.run_id) || | ||
| matrix.pr_number || | ||
| github.run_id | ||
| }} | ||
| steps: | ||
| - name: Reconcile PR review state labels | ||
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | ||
| with: | ||
| retries: 3 | ||
| script: | | ||
| const { owner, repo } = context.repo; | ||
| // A queue-superseded shard leaves no other trace in the logs. | ||
| core.info(`Concurrency group: ${process.env.CONCURRENCY_GROUP}`); | ||
| const stateLabels = [ | ||
| 'awaiting-author', | ||
| 'awaiting-coderabbit', | ||
| 'awaiting-ready', | ||
| 'awaiting-maintainer', | ||
| 'awaiting-review', // Legacy label removed during reconciliation. | ||
| 'has-conflicts', | ||
| ]; | ||
| const labelDefinitions = [ | ||
| { | ||
| name: 'awaiting-coderabbit', | ||
| color: '5319e7', | ||
| description: 'Waiting for automated review of the latest commit', | ||
| }, | ||
| { | ||
| name: 'awaiting-ready', | ||
| color: '1d76db', | ||
| description: 'Automated review complete; waiting for the draft to be marked ready', | ||
| }, | ||
| { | ||
| name: 'awaiting-maintainer', | ||
| color: '0e8a16', | ||
| description: 'Waiting for fresh human maintainer or CODEOWNER approval', | ||
| }, | ||
| { | ||
| name: 'coderabbit-review-active', | ||
| color: '5319e7', | ||
| description: 'Required CI passed; CodeRabbit review is active', | ||
| }, | ||
| { | ||
| name: 'community-approved', | ||
| color: 'c2e0c6', | ||
| description: 'Fresh community approval on the current head; maintainer review still required', | ||
| }, | ||
| ]; | ||
| const guideMarker = '<!-- zoo-code-pr-review-process -->'; | ||
| const codeRabbitLabelMarkerPrefix = '<!-- coderabbit-review-label:'; | ||
| const codeRabbitLogin = 'coderabbitai[bot]'; | ||
| const codeRabbitLogins = new Set([codeRabbitLogin, 'coderabbitai']); | ||
| const codeRabbitActiveLabel = 'coderabbit-review-active'; | ||
| const codeRabbitEligibleBotLogins = new Set(['zoomote[bot]']); | ||
| // Advisory priority signal only — kept outside stateLabels so it never | ||
| // replaces or blocks the review-state queue labels. | ||
| const communityApprovedLabel = 'community-approved'; | ||
| const reviewGateName = 'Zoo Code / PR review gate'; | ||
| const reconciliationCheckName = 'Zoo Code / reconcile PR review state'; | ||
| if (context.eventName === 'issue_comment' && | ||
| (!context.payload.issue?.pull_request || | ||
| context.payload.comment?.user?.login?.toLowerCase() !== codeRabbitLogin)) { | ||
| core.info('Ignoring untrusted issue comment event'); | ||
| return; | ||
| } | ||
| // The resolve job fans out one shard per target PR; this job | ||
| // reconciles exactly this matrix shard's PR. The single-PR GET | ||
| // endpoint returns mergeable fields that list endpoints omit. | ||
| const { data: shardPr } = await github.rest.pulls.get({ | ||
| owner, | ||
| repo, | ||
| pull_number: Number(process.env.PR_NUMBER), | ||
| }); | ||
| const prs = shardPr.state === 'open' ? [shardPr] : []; | ||
| // pull_request_review runs originating from forks can still receive a read-only | ||
| // token. pull_request_target, schedule, and workflow_dispatch runs execute in the | ||
| // base repository context and can safely mutate metadata without trusting PR code. | ||
| // See: https://docs.github.com/en/actions/concepts/security/github_token | ||
| const isReadOnlyRun = context.eventName === 'pull_request_review' && | ||
| Boolean(context.payload.pull_request) && | ||
| context.payload.pull_request.head?.repo?.full_name !== | ||
| context.payload.pull_request.base?.repo?.full_name; | ||
| function isForkPR(pr) { | ||
| return pr.head?.repo?.full_name && pr.head.repo.full_name !== pr.base?.repo?.full_name; | ||
| } | ||
| if (!isReadOnlyRun) { | ||
| // One paginated list keeps the per-shard API budget flat no matter | ||
| // how many labels are managed; per-label getLabel calls would | ||
| // multiply across a full sweep against the Actions token pool. | ||
| const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { | ||
| owner, repo, per_page: 100, | ||
| }); | ||
| const existingLabelNames = new Set(repoLabels.map(label => label.name)); | ||
| for (const label of labelDefinitions) { | ||
| if (existingLabelNames.has(label.name)) continue; | ||
| try { | ||
| await github.rest.issues.createLabel({ owner, repo, ...label }); | ||
| } catch (createError) { | ||
| // Concurrent shards for other PRs can win the creation race. | ||
| // Tolerate only the duplicate-label payload; any other 422 | ||
| // must fail the shard. | ||
| const isDuplicateRace = createError.status === 422 && | ||
| (createError.response?.data?.errors ?? []).some( | ||
| error => error.code === 'already_exists' && error.field === 'name' | ||
| ); | ||
| if (!isDuplicateRace) throw createError; | ||
| } | ||
| } | ||
| } | ||
| // Strips stateLabels from a PR, optionally keeping one. | ||
| // Also removes stale-awaiting-author when not keeping awaiting-author. | ||
| // Only skipped when this run's own token is read-only (see isReadOnlyRun) — | ||
| // schedule/workflow_dispatch runs reconcile fork PRs normally. | ||
| async function reconcileLabels(pr, desiredLabel) { | ||
| if (isReadOnlyRun && isForkPR(pr)) { | ||
| core.info(`PR #${pr.number}: fork PR on a read-only run — skipping label mutation`); | ||
| return; | ||
| } | ||
| const currentLabels = new Set(pr.labels.map(l => l.name)); | ||
| const labelErrors = []; | ||
| if (desiredLabel && !currentLabels.has(desiredLabel)) { | ||
| try { | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: pr.number, labels: [desiredLabel], | ||
| }); | ||
| currentLabels.add(desiredLabel); | ||
| pr.labels.push({ name: desiredLabel }); | ||
| } catch (err) { | ||
| const error = new Error(`Could not add desired label: ${err.message}`); | ||
| error.preserveStateLabels = true; | ||
| throw error; | ||
| } | ||
| } | ||
| for (const label of stateLabels) { | ||
| if (label !== desiredLabel && currentLabels.has(label)) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: label, | ||
| }); | ||
| currentLabels.delete(label); | ||
| pr.labels = pr.labels.filter(current => current.name !== label); | ||
| } catch (err) { | ||
| if (err.status === 404) { | ||
| currentLabels.delete(label); | ||
| pr.labels = pr.labels.filter(current => current.name !== label); | ||
| } else { | ||
| labelErrors.push(err); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| if (desiredLabel !== 'awaiting-author' && currentLabels.has('stale-awaiting-author')) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: 'stale-awaiting-author', | ||
| }); | ||
| currentLabels.delete('stale-awaiting-author'); | ||
| pr.labels = pr.labels.filter(current => current.name !== 'stale-awaiting-author'); | ||
| } catch (err) { | ||
| if (err.status === 404) { | ||
| currentLabels.delete('stale-awaiting-author'); | ||
| pr.labels = pr.labels.filter(current => current.name !== 'stale-awaiting-author'); | ||
| } else { | ||
| labelErrors.push(err); | ||
| } | ||
| } | ||
| } | ||
| if (labelErrors.length > 0) { | ||
| throw new Error(`Could not reconcile labels: ${labelErrors.map(err => err.message).join('; ')}`); | ||
| } | ||
| } | ||
| async function findReviewGuide(pr) { | ||
| const comments = await github.paginate(github.rest.issues.listComments, { | ||
| owner, repo, issue_number: pr.number, per_page: 100, | ||
| }); | ||
| return comments.find(comment => | ||
| comment.user?.login === 'github-actions[bot]' && comment.body?.includes(guideMarker) | ||
| ); | ||
| } | ||
| async function setCodeRabbitReviewActive(pr, enabled, recycle = false) { | ||
| if (isReadOnlyRun && isForkPR(pr)) return; | ||
| const hasLabel = pr.labels.some(label => label.name === codeRabbitActiveLabel); | ||
| if (enabled && hasLabel && recycle) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: codeRabbitActiveLabel, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
| pr.labels = pr.labels.filter(label => label.name !== codeRabbitActiveLabel); | ||
| } | ||
| if (enabled && (!hasLabel || recycle)) { | ||
| let addError = null; | ||
| for (let attempt = 1; attempt <= 2; attempt++) { | ||
| try { | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: pr.number, labels: [codeRabbitActiveLabel], | ||
| }); | ||
| if (!pr.labels.some(label => label.name === codeRabbitActiveLabel)) { | ||
| pr.labels.push({ name: codeRabbitActiveLabel }); | ||
| } | ||
| delete pr.codeRabbitActivationUncertain; | ||
| return; | ||
| } catch (error) { | ||
| addError = error; | ||
| if (attempt === 1) { | ||
| core.warning(`PR #${pr.number}: retrying CodeRabbit activation label: ${error.message}`); | ||
| } | ||
| } | ||
| } | ||
| pr.codeRabbitActivationUncertain = true; | ||
| try { | ||
| const { data: issue } = await github.rest.issues.get({ | ||
| owner, repo, issue_number: pr.number, | ||
| }); | ||
| pr.labels = issue.labels.map(label => | ||
| typeof label === 'string' ? { name: label } : label | ||
| ); | ||
| if (pr.labels.some(label => label.name === codeRabbitActiveLabel)) { | ||
| delete pr.codeRabbitActivationUncertain; | ||
| return; | ||
| } | ||
| } catch (verificationError) { | ||
| core.warning( | ||
| `PR #${pr.number}: could not verify CodeRabbit activation label: ${verificationError.message}` | ||
| ); | ||
| } | ||
| throw addError; | ||
| } else if (!enabled && (hasLabel || pr.codeRabbitActivationUncertain)) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: codeRabbitActiveLabel, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
| pr.labels = pr.labels.filter(label => label.name !== codeRabbitActiveLabel); | ||
| delete pr.codeRabbitActivationUncertain; | ||
| } | ||
| } | ||
| // Reconciles the advisory community-approved label. The label means | ||
| // "fresh community code approval for the current head; maintainer | ||
| // review still required" — it is a priority signal only and never | ||
| // grants merge authority. Every reconciliation branch routes through | ||
| // this one helper with a single decision: | ||
| // 'add' — stable head, maintainer queue, qualifying fresh approval | ||
| // 'remove' — known non-maintainer phase, known head change, or | ||
| // invalidated approval (known invalidation beats uncertainty) | ||
| // 'preserve' — unknown mergeability, or a permission lookup that | ||
| // fails only in the community check, with no known | ||
| // invalidation. A lookup failure that also blocks | ||
| // maintainer classification fails the PR and removes | ||
| // the label through the outer catch instead. | ||
| // A failed community label update is advisory: it must not clear the | ||
| // queue label or fail the review gate, so errors are logged, not thrown. | ||
| async function reconcileCommunityApproval(pr, decision) { | ||
| if (isReadOnlyRun && isForkPR(pr)) return; | ||
| if (decision === 'preserve') return; | ||
| const hasLabel = pr.labels.some(label => label.name === communityApprovedLabel); | ||
| try { | ||
| if (decision === 'add' && !hasLabel) { | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: pr.number, labels: [communityApprovedLabel], | ||
| }); | ||
| pr.labels.push({ name: communityApprovedLabel }); | ||
| } else if (decision === 'remove' && hasLabel) { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: communityApprovedLabel, | ||
| }); | ||
| pr.labels = pr.labels.filter(label => label.name !== communityApprovedLabel); | ||
| } | ||
| } catch (error) { | ||
| if (decision === 'remove' && error.status === 404) { | ||
| pr.labels = pr.labels.filter(label => label.name !== communityApprovedLabel); | ||
| return; | ||
| } | ||
| core.warning( | ||
| `PR #${pr.number}: could not ${decision} ${communityApprovedLabel}: ${error.message}` | ||
| ); | ||
| } | ||
| } | ||
| function codeRabbitLabelHead(comment) { | ||
| const markerPattern = new RegExp(`${codeRabbitLabelMarkerPrefix}([a-f0-9]{40}) -->`); | ||
| const match = comment?.body?.match(markerPattern); | ||
| return match?.[1] ?? null; | ||
| } | ||
| // Collaborator permissions are repository-level, so memoize them for | ||
| // the whole run: a maintainer's current-head CHANGES_REQUESTED reaches | ||
| // both review loops, and scheduled sweeps reconcile every open PR. | ||
| const permissionCache = new Map(); | ||
| async function permissionFor(username) { | ||
| const key = username.toLowerCase(); | ||
| if (permissionCache.has(key)) return permissionCache.get(key); | ||
| try { | ||
| const result = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner, repo, username, | ||
| }); | ||
| permissionCache.set(key, result.data.permission); | ||
| return result.data.permission; | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| permissionCache.set(key, 'none'); | ||
| return 'none'; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| function phaseMessage(phase) { | ||
| const messages = { | ||
| draft: 'Mark the PR ready. Required CI must pass before CodeRabbit starts.', | ||
| conflict: 'Resolve the merge conflicts. The review sequence resumes after the branch is mergeable.', | ||
| 'ci-pending': 'Wait for required CI checks; awaiting-maintainer requires CI and automated review completion.', | ||
| 'ci-failed': 'Fix the failing required CI checks; awaiting-maintainer requires CI and automated review completion.', | ||
| 'mergeability-pending': 'Wait for GitHub to finish calculating mergeability.', | ||
| 'configuration-error': 'Repository rules must not require this advisory workflow\'s own gate or reconciliation job.', | ||
| 'coderabbit-changes': 'Address automated review findings and push fixes.', | ||
| coderabbit: 'Required CI passed. Waiting for automated review of the latest commit.', | ||
| 'draft-approved': 'Automated review complete for the latest commit. Mark the draft ready.', | ||
| 'maintainer-changes': 'Address maintainer or CODEOWNER feedback, push an update, then re-request review from the blocking maintainer.', | ||
| maintainer: 'Awaiting fresh human maintainer or CODEOWNER approval.', | ||
| approved: 'The required review sequence passed. Remaining merge requirements apply.', | ||
| }; | ||
| return messages[phase]; | ||
| } | ||
| async function updateReviewGate(pr, phase, passed, required = false) { | ||
| if (isReadOnlyRun && isForkPR(pr)) return; | ||
| const state = passed ? 'success' : 'pending'; | ||
| const description = phaseMessage(phase); | ||
| let latestGateStatus = null; | ||
| let lookupSucceeded = false; | ||
| try { | ||
| const { data: combinedStatus } = await github.rest.repos.getCombinedStatusForRef({ | ||
| owner, repo, ref: pr.head.sha, | ||
| }); | ||
| lookupSucceeded = true; | ||
| latestGateStatus = combinedStatus.statuses | ||
| .filter(status => status.context === reviewGateName) | ||
| .sort((a, b) => b.id - a.id)[0]; | ||
| } catch (error) { | ||
| core.warning(`PR #${pr.number}: could not inspect ${reviewGateName}: ${error.message}`); | ||
| } | ||
| if (!lookupSucceeded && passed) return; | ||
| if (latestGateStatus?.state === state && | ||
| latestGateStatus.description === description && | ||
| latestGateStatus.target_url === pr.html_url) { | ||
| return; | ||
| } | ||
| const mustInvalidateSuccess = !passed && | ||
| (!lookupSucceeded || latestGateStatus?.state === 'success'); | ||
| try { | ||
| await github.rest.repos.createCommitStatus({ | ||
| owner, | ||
| repo, | ||
| sha: pr.head.sha, | ||
| state, | ||
| context: reviewGateName, | ||
| description, | ||
| target_url: pr.html_url, | ||
| }); | ||
| } catch (error) { | ||
| if (required || mustInvalidateSuccess) throw error; | ||
| core.warning(`PR #${pr.number}: could not publish ${reviewGateName}: ${error.message}`); | ||
| } | ||
| } | ||
| function reviewGuideBody(pr, phase, activationPending = false) { | ||
| const automatedAuthor = pr.user?.type === 'Bot'; | ||
| const authorNote = automatedAuthor | ||
| ? 'This PR was opened by an automated account. A human maintainer must verify the change intent, provenance, and validation before merging.' | ||
| : 'Thanks for contributing. This comment tracks the review sequence and the next action.'; | ||
| const labelMarker = phase === 'coderabbit' | ||
| ? `\n${codeRabbitLabelMarkerPrefix}${pr.head.sha}${activationPending ? ':pending' : ''} -->` | ||
| : ''; | ||
| const phaseHelp = phase === 'coderabbit-changes' | ||
| ? '\n\nAfter fixes are pushed and required CI passes, automated review restarts.' | ||
| : phase === 'coderabbit' | ||
| ? '\n\nIf automated review does not start, a maintainer must restart it.' | ||
| : phase === 'maintainer' && !automatedAuthor | ||
| ? '\n\nAutomated review is complete for the latest commit but does not replace human approval.' | ||
| : ''; | ||
| return `${guideMarker}\n### Review status\n\n${authorNote}\n\n` + | ||
| `**Current step:** ${phaseMessage(phase)}${phaseHelp}\n\n` + | ||
| 'Review-state labels are managed by this workflow; do not edit them manually. ' + | ||
| '`community-approved` is managed the same way — do not add or remove it manually. ' + | ||
| 'It signals a fresh community code approval for the current head as an advisory ' + | ||
| 'priority only; maintainer review is still required.' + | ||
| labelMarker; | ||
| } | ||
| async function updateReviewGuide(pr, phase, existingGuide = null, activationPending = false) { | ||
| if (isReadOnlyRun && isForkPR(pr)) { | ||
| core.info(`PR #${pr.number}: fork PR on a read-only run — skipping guide update`); | ||
| return; | ||
| } | ||
| const body = reviewGuideBody(pr, phase, activationPending); | ||
| const existing = existingGuide ?? await findReviewGuide(pr); | ||
| if (!existing) { | ||
| const { data: created } = await github.rest.issues.createComment({ | ||
| owner, repo, issue_number: pr.number, body, | ||
| }); | ||
| return created; | ||
| } else if (existing.body !== body) { | ||
| await github.rest.issues.updateComment({ | ||
| owner, repo, comment_id: existing.id, body, | ||
| }); | ||
| return { ...existing, body }; | ||
| } | ||
| return existing; | ||
| } | ||
| // Fetch required status check names from the branch ruleset. | ||
| // Uses the public /rules/branches endpoint — no admin token needed. | ||
| // Fails closed if the endpoint is unavailable. | ||
| let requiredChecks = null; | ||
| try { | ||
| const rules = await github.paginate( | ||
| 'GET /repos/{owner}/{repo}/rules/branches/{branch}', | ||
| { owner, repo, branch: 'main', per_page: 100 }, | ||
| ); | ||
| const statusRules = rules.filter(rule => rule.type === 'required_status_checks'); | ||
| requiredChecks = []; | ||
| if (statusRules.length > 0) { | ||
| const uniqueChecks = new Map(); | ||
| for (const rule of statusRules) { | ||
| for (const check of rule.parameters.required_status_checks) { | ||
| const integrationId = check.integration_id ?? null; | ||
| uniqueChecks.set(`${check.context}:${integrationId ?? 'any'}`, { | ||
| context: check.context, | ||
| integrationId, | ||
| }); | ||
| } | ||
| } | ||
| requiredChecks = [...uniqueChecks.values()]; | ||
| core.info( | ||
| `Required checks: ${requiredChecks.map(check => | ||
| `${check.context}${check.integrationId ? `@${check.integrationId}` : ''}` | ||
| ).join(', ')}` | ||
| ); | ||
| } | ||
| } catch (err) { | ||
| core.warning(`Could not fetch branch rules; review gate remains pending: ${err.message}`); | ||
| } | ||
| const failures = []; | ||
| for (const pr of prs) { | ||
| let failurePhase = 'ci-pending'; | ||
| try { | ||
| // The matrix job reports one check per shard named | ||
| // "<reconciliationCheckName> (<pr_number>)", so catch both the | ||
| // bare name and any matrix-suffixed variant. | ||
| const selfReferentialRequirements = (requiredChecks ?? []).filter(check => | ||
| check.context === reviewGateName || | ||
| check.context === reconciliationCheckName || | ||
| check.context.startsWith(`${reconciliationCheckName} (`) | ||
| ); | ||
| if (selfReferentialRequirements.length > 0) { | ||
| failurePhase = 'configuration-error'; | ||
| const contexts = selfReferentialRequirements.map(check => check.context).join(', '); | ||
| core.warning( | ||
| `PR #${pr.number}: unsupported self-referential required check configuration: ${contexts}` | ||
| ); | ||
| await updateReviewGate(pr, 'configuration-error', false, true); | ||
| const existingGuide = await findReviewGuide(pr); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| await reconcileLabels(pr, null); | ||
| await reconcileCommunityApproval(pr, 'remove'); | ||
| await updateReviewGuide(pr, 'configuration-error', existingGuide); | ||
| continue; | ||
| } | ||
| let existingGuide = await findReviewGuide(pr); | ||
| // `mergeable`/`mergeable_state` are only returned by the single-PR | ||
| // GET endpoint and are computed asynchronously by GitHub — this | ||
| // shard's own pulls.get above provides them, but even that can | ||
| // return `null`/"unknown" while the merge check is still running. | ||
| // Treat "unknown" as not-yet-computed rather than as conflicting. | ||
| const prDetail = pr; | ||
| if (prDetail.mergeable === false && prDetail.mergeable_state === 'dirty') { | ||
| core.info(`PR #${pr.number}: has merge conflicts — labeling has-conflicts`); | ||
| await updateReviewGate(pr, 'conflict', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| await reconcileLabels(pr, 'has-conflicts'); | ||
| await reconcileCommunityApproval(pr, 'remove'); | ||
| await updateReviewGuide(pr, 'conflict', existingGuide); | ||
| continue; | ||
| } | ||
| // Check CI status for required checks on the PR's head commit only. | ||
| // Scoping to required checks avoids advisory checks (e.g. codecov/patch) | ||
| // incorrectly blocking label assignment on otherwise-ready PRs. | ||
| const checkRuns = await github.paginate(github.rest.checks.listForRef, { | ||
| owner, repo, ref: pr.head.sha, per_page: 100, | ||
| }); | ||
| const needsLegacyStatuses = requiredChecks?.some( | ||
| check => check.integrationId === null | ||
| ); | ||
| const commitStatuses = needsLegacyStatuses | ||
| ? await github.paginate(github.rest.repos.listCommitStatusesForRef, { | ||
| owner, repo, ref: pr.head.sha, per_page: 100, | ||
| }) | ||
| : []; | ||
| // listForRef returns every check run ever recorded on the ref, including | ||
| // stale superseded ones (e.g. a failed run later re-run green). Branch | ||
| // protection and the PR UI only consider the latest run per check name, so | ||
| // reduce to that before evaluating — otherwise a single stale failure makes | ||
| // ciFailed true forever and state labels never come back. See issue #884. | ||
| // | ||
| // Unlike listReviews (which documents oldest-first order), listForRef's | ||
| // ordering is unspecified, so we pick the latest by run.id — GitHub assigns | ||
| // monotonically increasing IDs, and id is never null (a freshly re-queued | ||
| // run can have started_at: null, which would lose a string comparison | ||
| // against an older completed run's timestamp). | ||
| const latestByName = new Map(); | ||
| const latestByNameAndApp = new Map(); | ||
| for (const run of checkRuns) { | ||
| const prev = latestByName.get(run.name); | ||
| if (!prev || run.id > prev.id) { | ||
| latestByName.set(run.name, run); | ||
| } | ||
| const appKey = `${run.name}:${run.app?.id ?? 'none'}`; | ||
| const previousAppRun = latestByNameAndApp.get(appKey); | ||
| if (!previousAppRun || run.id > previousAppRun.id) { | ||
| latestByNameAndApp.set(appKey, run); | ||
| } | ||
| } | ||
| const latestStatusByContext = new Map(); | ||
| for (const status of commitStatuses) { | ||
| const previous = latestStatusByContext.get(status.context); | ||
| if (!previous || status.id > previous.id) { | ||
| latestStatusByContext.set(status.context, status); | ||
| } | ||
| } | ||
| // Evaluate every required rule exactly as GitHub reports it. Workflow | ||
| // ownership cannot be inferred from the shared GitHub Actions app ID. | ||
| const requiredSpecs = requiredChecks ?? []; | ||
| const requiredResults = requiredSpecs.map(check => { | ||
| const run = check.integrationId === null | ||
| ? latestByName.get(check.context) | ||
| : latestByNameAndApp.get(`${check.context}:${check.integrationId}`); | ||
| const status = check.integrationId === null | ||
| ? latestStatusByContext.get(check.context) | ||
| : null; | ||
| return { check, run, status }; | ||
| }); | ||
| const relevantRuns = requiredResults.map(result => result.run).filter(Boolean); | ||
| const relevantStatuses = requiredResults.map(result => result.status).filter(Boolean); | ||
| const missingRequiredChecks = requiredResults | ||
| .filter(result => !result.run && !result.status) | ||
| .map(result => | ||
| `${result.check.context}${result.check.integrationId ? `@${result.check.integrationId}` : ''}` | ||
| ); | ||
| core.debug( | ||
| `PR #${pr.number}: ${relevantRuns.length} check run(s), ` + | ||
| `${relevantStatuses.length} commit status(es), ` + | ||
| `missing=[${missingRequiredChecks.join(', ')}]` | ||
| ); | ||
| for (const run of relevantRuns) { | ||
| core.debug(` check: "${run.name}" status=${run.status} conclusion=${run.conclusion}`); | ||
| } | ||
| const ciFailed = relevantRuns.some( | ||
| run => run.status === 'completed' && | ||
| run.conclusion !== 'success' && | ||
| run.conclusion !== 'skipped' && | ||
| run.conclusion !== 'neutral', | ||
| ) || relevantStatuses.some( | ||
| status => status.state === 'failure' || status.state === 'error' | ||
| ); | ||
| const ciPending = !ciFailed && ( | ||
| requiredChecks === null || missingRequiredChecks.length > 0 || relevantRuns.some( | ||
| run => run.status !== 'completed', | ||
| ) || relevantStatuses.some( | ||
| status => status.state === 'pending' | ||
| ) | ||
| ); | ||
| // While CI is running or has failed, remove state labels and move on. | ||
| // CI failure is its own signal; the label would add noise, not clarity. | ||
| // Exception (issue #2025): while GitHub is still computing | ||
| // mergeability, a missing required check can simply mean a | ||
| // conflicting PR got no merge-ref CI run — not that its | ||
| // conflicts went away. Keep an existing has-conflicts label | ||
| // through this branch instead of stripping it with the other | ||
| // state labels. Known-clean mergeability keeps the strip, and | ||
| // known-dirty never reaches here (the conflict check above | ||
| // handles it first). | ||
| if (ciPending || ciFailed) { | ||
| const mergeabilityUnknown = | ||
| prDetail.mergeable === null || prDetail.mergeable_state === 'unknown'; | ||
| const keepHasConflicts = mergeabilityUnknown && | ||
| pr.labels.some(label => label.name === 'has-conflicts'); | ||
| core.info( | ||
| `PR #${pr.number}: CI ${ciPending ? 'pending' : 'failed'} — stripping state labels` + | ||
| (keepHasConflicts ? ' (keeping has-conflicts while mergeability is unknown)' : '') | ||
| ); | ||
| await updateReviewGate(pr, ciPending ? 'ci-pending' : 'ci-failed', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| await reconcileLabels(pr, keepHasConflicts ? 'has-conflicts' : null); | ||
| await reconcileCommunityApproval(pr, 'remove'); | ||
| await updateReviewGuide( | ||
| pr, | ||
| ciPending ? 'ci-pending' : 'ci-failed', | ||
| existingGuide | ||
| ); | ||
| continue; | ||
| } | ||
| // CI is passing. Now determine review state. | ||
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | ||
| owner, repo, pull_number: pr.number, per_page: 100, | ||
| }); | ||
| // Reduce to each reviewer's latest meaningful state. | ||
| // Reviews are returned oldest-first, so last-write-wins yields the latest state. | ||
| // COMMENTED and DISMISSED are treated as neutral — they do not | ||
| // block the PR or indicate the author needs to act. | ||
| const latest = new Map(); | ||
| for (const r of reviews) { | ||
| // Ghost accounts have no usable identity; their reviews carry | ||
| // no actionable state for either review loop. | ||
| const reviewer = r.user?.login?.toLowerCase(); | ||
| if (!reviewer) continue; | ||
| const reviewerKey = codeRabbitLogins.has(reviewer) ? codeRabbitLogin : reviewer; | ||
| if (r.state === 'DISMISSED') { | ||
| latest.delete(reviewerKey); | ||
| } else if (r.state !== 'COMMENTED') { | ||
| latest.set(reviewerKey, r); | ||
| } | ||
| } | ||
| // Durable per-maintainer change-request blockers (issue #1671). | ||
| // Unlike approvals and CodeRabbit reviews, a human maintainer's | ||
| // CHANGES_REQUESTED stays binding across author pushes, base-branch | ||
| // merges, CI runs, and CodeRabbit reviews until that same | ||
| // maintainer's blocker is cleared by one of: | ||
| // 1. anyone re-requesting review from them (issue #2026), | ||
| // 2. a newer review from that maintainer (its state decides), or | ||
| // 3. GitHub dismissing the blocking review. | ||
| // Latest state per reviewer is keyed by review id (monotonically | ||
| // increasing) so reordered or duplicate history cannot change the | ||
| // result. COMMENTED reviews are neutral and never clear a blocker; | ||
| // a DISMISSED latest review clears it. | ||
| const latestHumanReview = new Map(); | ||
| for (const r of reviews) { | ||
| const reviewer = r.user?.login?.toLowerCase(); | ||
| if (!reviewer || | ||
| r.user?.type === 'Bot' || | ||
| codeRabbitLogins.has(reviewer) || | ||
| reviewer === pr.user?.login?.toLowerCase() || | ||
| r.state === 'COMMENTED') { | ||
| continue; | ||
| } | ||
| const previous = latestHumanReview.get(reviewer); | ||
| if (!previous || r.id > previous.id) { | ||
| latestHumanReview.set(reviewer, r); | ||
| } | ||
| } | ||
| const maintainerBlockers = new Map(); | ||
| for (const [reviewer, review] of latestHumanReview) { | ||
| if (review.state !== 'CHANGES_REQUESTED') continue; | ||
| if (['admin', 'maintain', 'write'].includes(await permissionFor(review.user.login))) { | ||
| maintainerBlockers.set(reviewer, review); | ||
| } | ||
| } | ||
| // Clear blockers that were explicitly re-requested. A | ||
| // review_requested timeline event clears that maintainer's | ||
| // blocker no matter who made the request (issue #2026) — the | ||
| // re-request itself signals the blocker should take another | ||
| // look. Team requests | ||
| // carry no requested_reviewer and never clear an individual | ||
| // blocker; review_request_removed events only trigger | ||
| // reconciliation and are not clearing evidence. | ||
| // If the timeline cannot be reconstructed, fail closed: keep every | ||
| // blocker so awaiting-author is preserved. | ||
| if (maintainerBlockers.size > 0) { | ||
| let timelineEvents = null; | ||
| try { | ||
| timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { | ||
| owner, repo, issue_number: pr.number, per_page: 100, | ||
| }); | ||
| } catch (error) { | ||
| core.warning( | ||
| `PR #${pr.number}: could not reconstruct review-request history; ` + | ||
| `preserving maintainer blockers: ${error.message}` | ||
| ); | ||
| } | ||
| if (timelineEvents) { | ||
| for (const event of timelineEvents) { | ||
| if (event.event !== 'review_requested') continue; | ||
| const requested = event.requested_reviewer?.login?.toLowerCase(); | ||
| if (!requested) continue; | ||
| const blocker = maintainerBlockers.get(requested); | ||
| if (!blocker) continue; | ||
| const requestedAt = Date.parse(event.created_at ?? ''); | ||
| const blockedAt = Date.parse(blocker.submitted_at ?? ''); | ||
| // A re-request clears a blocker only when it was created | ||
| // at the same time as or after the blocking review; | ||
| // missing or unparsable timestamps fail closed and keep | ||
| // the blocker. | ||
| if (!Number.isNaN(requestedAt) && !Number.isNaN(blockedAt) && requestedAt >= blockedAt) { | ||
| maintainerBlockers.delete(requested); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const codeRabbitReview = latest.get(codeRabbitLogin); | ||
| const freshCodeRabbitReview = codeRabbitReview?.commit_id === pr.head.sha | ||
| ? codeRabbitReview | ||
| : null; | ||
| const freshMaintainerReviews = []; | ||
| for (const review of latest.values()) { | ||
| if (review.commit_id !== pr.head.sha || | ||
| review.user?.type === 'Bot' || | ||
| codeRabbitLogins.has(review.user?.login.toLowerCase()) || | ||
| review.user?.login.toLowerCase() === pr.user?.login.toLowerCase()) { | ||
| continue; | ||
| } | ||
| if (['admin', 'maintain', 'write'].includes(await permissionFor(review.user.login))) { | ||
| freshMaintainerReviews.push(review); | ||
| } | ||
| } | ||
| // Community approval eligibility reuses the decisive per-reviewer | ||
| // reduction above: the reviewer's latest decisive review (selected | ||
| // by review id, not array order) must be an approval on the current | ||
| // head. Ghost accounts, bots, CodeRabbit, and the PR author are | ||
| // already excluded there; comments are neutral. Permission results | ||
| // are shared with maintainer classification through permissionFor: | ||
| // a 404 qualifies an otherwise valid reviewer, while a lookup | ||
| // failure is unknown permission and never counts as community | ||
| // eligibility. One eligible approval is enough; bot-authored PRs | ||
| // follow the same rule. A known invalidation — an approval that | ||
| // was dismissed, replaced, or left behind by a head change — | ||
| // takes priority over any lookup uncertainty. | ||
| const approvedByReviewer = new Set(); | ||
| for (const r of reviews) { | ||
| const reviewer = r.user?.login?.toLowerCase(); | ||
| if (reviewer && r.state === 'APPROVED') approvedByReviewer.add(reviewer); | ||
| } | ||
| let communityApprovalFresh = false; | ||
| let communityPermissionUncertain = false; | ||
| let communityApprovalInvalidated = false; | ||
| for (const [reviewer, review] of latestHumanReview) { | ||
| if (review.state === 'APPROVED' && review.commit_id === pr.head.sha) { | ||
| let permission; | ||
| try { | ||
| permission = await permissionFor(review.user.login); | ||
| } catch (error) { | ||
| communityPermissionUncertain = true; | ||
| continue; | ||
| } | ||
| if (!['admin', 'maintain', 'write'].includes(permission)) { | ||
| communityApprovalFresh = true; | ||
| break; | ||
| } | ||
| } else if (approvedByReviewer.has(reviewer)) { | ||
| communityApprovalInvalidated = true; | ||
| } | ||
| } | ||
| const automatedAuthor = pr.user?.type === 'Bot'; | ||
| const codeRabbitEligibleAuthor = !automatedAuthor || | ||
| codeRabbitEligibleBotLogins.has(pr.user?.login.toLowerCase()); | ||
| const codeRabbitReviewComplete = freshCodeRabbitReview?.state === 'APPROVED'; | ||
| const codeRabbitChangesRequested = freshCodeRabbitReview?.state === 'CHANGES_REQUESTED'; | ||
| const maintainerApproval = freshMaintainerReviews | ||
| .filter(review => review.state === 'APPROVED') | ||
| .sort((a, b) => b.id - a.id)[0]; | ||
| const maintainerApprovedAfterAutomatedReview = codeRabbitReviewComplete && | ||
| maintainerApproval && | ||
| maintainerApproval.id > freshCodeRabbitReview.id; | ||
| let desiredLabel; | ||
| let phase; | ||
| let activateCodeRabbit = false; | ||
| let recycleCodeRabbitLabel = false; | ||
| if (codeRabbitChangesRequested || maintainerBlockers.size > 0) { | ||
| desiredLabel = 'awaiting-author'; | ||
| phase = codeRabbitChangesRequested ? 'coderabbit-changes' : 'maintainer-changes'; | ||
| } else if (!codeRabbitEligibleAuthor) { | ||
| if (pr.draft) { | ||
| desiredLabel = null; | ||
| phase = 'draft'; | ||
| } else if (!maintainerApproval) { | ||
| desiredLabel = 'awaiting-maintainer'; | ||
| phase = 'maintainer'; | ||
| } else { | ||
| desiredLabel = null; | ||
| phase = 'approved'; | ||
| } | ||
| } else if (!codeRabbitReviewComplete) { | ||
| if (pr.draft) { | ||
| desiredLabel = null; | ||
| phase = 'draft'; | ||
| } else { | ||
| activateCodeRabbit = true; | ||
| recycleCodeRabbitLabel = codeRabbitLabelHead(existingGuide) !== pr.head.sha; | ||
| desiredLabel = 'awaiting-coderabbit'; | ||
| phase = 'coderabbit'; | ||
| } | ||
| } else if (pr.draft) { | ||
| desiredLabel = 'awaiting-ready'; | ||
| phase = 'draft-approved'; | ||
| } else if (!maintainerApprovedAfterAutomatedReview) { | ||
| desiredLabel = 'awaiting-maintainer'; | ||
| phase = 'maintainer'; | ||
| } else { | ||
| desiredLabel = null; | ||
| phase = 'approved'; | ||
| } | ||
| // Advisory community label decision for the branches below. | ||
| // Non-maintainer phases always remove it; the maintainer queue | ||
| // refines the decision after the head/mergeability refetch. | ||
| let communityDecision = 'remove'; | ||
| if (desiredLabel === 'awaiting-maintainer') { | ||
| const { data: latestPrDetail } = await github.rest.pulls.get({ | ||
| owner, repo, pull_number: pr.number, | ||
| }); | ||
| // A head change since the event fetch invalidates every | ||
| // approval, even if another check is uncertain. | ||
| const headChanged = Boolean(latestPrDetail.head?.sha) && | ||
| latestPrDetail.head.sha !== pr.head.sha; | ||
| if (latestPrDetail.mergeable === false && latestPrDetail.mergeable_state === 'dirty') { | ||
| core.info(`PR #${pr.number}: has merge conflicts — labeling has-conflicts`); | ||
| await updateReviewGate(pr, 'conflict', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| await reconcileLabels(pr, 'has-conflicts'); | ||
| await reconcileCommunityApproval(pr, 'remove'); | ||
| await updateReviewGuide(pr, 'conflict', existingGuide); | ||
| continue; | ||
| } | ||
| if (latestPrDetail.mergeable === null || latestPrDetail.mergeable_state === 'unknown') { | ||
| try { | ||
| core.info(`PR #${pr.number}: mergeability pending — preserving current state labels`); | ||
| await updateReviewGate(pr, 'mergeability-pending', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| await updateReviewGuide(pr, 'mergeability-pending', existingGuide); | ||
| } catch (error) { | ||
| error.preserveStateLabels = true; | ||
| throw error; | ||
| } | ||
| // Unknown mergeability alone, with an unchanged head and no | ||
| // known invalidation, preserves an existing label and never adds it. | ||
| await reconcileCommunityApproval( | ||
| pr, | ||
| headChanged || communityApprovalInvalidated ? 'remove' : 'preserve' | ||
| ); | ||
| continue; | ||
| } | ||
| communityDecision = headChanged | ||
| ? 'remove' | ||
| : communityApprovalFresh | ||
| ? 'add' | ||
| : communityApprovalInvalidated | ||
| ? 'remove' | ||
| : communityPermissionUncertain | ||
| ? 'preserve' | ||
| : 'remove'; | ||
| } | ||
| core.info( | ||
| `PR #${pr.number}: CI passing, reviews=${latest.size}, ` + | ||
| `coderabbit=${freshCodeRabbitReview?.state ?? (codeRabbitEligibleAuthor ? 'pending' : 'optional')}, ` + | ||
| `maintainer=${maintainerApproval?.state ?? 'pending'}, blockers=${maintainerBlockers.size} → ${desiredLabel ?? '(none)'}` | ||
| ); | ||
| const readyForMaintainer = phase === 'maintainer' || phase === 'approved'; | ||
| if (!readyForMaintainer) { | ||
| await updateReviewGate(pr, phase, false); | ||
| } | ||
| const recyclingActiveLabel = activateCodeRabbit && recycleCodeRabbitLabel && | ||
| pr.labels.some(label => label.name === codeRabbitActiveLabel); | ||
| existingGuide = await updateReviewGuide( | ||
| pr, | ||
| phase, | ||
| existingGuide, | ||
| recyclingActiveLabel, | ||
| ); | ||
| await setCodeRabbitReviewActive(pr, activateCodeRabbit, recycleCodeRabbitLabel); | ||
| if (recyclingActiveLabel) { | ||
| await updateReviewGuide(pr, phase, existingGuide); | ||
| } | ||
| await reconcileLabels(pr, desiredLabel); | ||
| await reconcileCommunityApproval(pr, communityDecision); | ||
| if (readyForMaintainer) { | ||
| await updateReviewGate(pr, phase, true); | ||
| } | ||
| } catch (error) { | ||
| let invalidationError = null; | ||
| const metadataErrors = []; | ||
| try { | ||
| await updateReviewGate(pr, failurePhase, false, true); | ||
| } catch (gateError) { | ||
| invalidationError = gateError; | ||
| } | ||
| try { | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| } catch (cleanupError) { | ||
| metadataErrors.push(cleanupError); | ||
| } | ||
| if (!error.preserveStateLabels) { | ||
| try { | ||
| await reconcileLabels(pr, null); | ||
| } catch (cleanupError) { | ||
| metadataErrors.push(cleanupError); | ||
| } | ||
| // A global failure that clears the maintainer queue clears the | ||
| // advisory community signal too. | ||
| await reconcileCommunityApproval(pr, 'remove'); | ||
| } | ||
| const detail = error.status | ||
| ? `${error.message} (HTTP ${error.status}${error.response?.data?.message ? `: ${error.response.data.message}` : ''})` | ||
| : error.message; | ||
| const gateDetail = invalidationError | ||
| ? `; could not invalidate ${reviewGateName}: ${invalidationError.message}` | ||
| : ''; | ||
| const metadataDetail = metadataErrors.length > 0 | ||
| ? `; could not clear review metadata: ${metadataErrors.map(err => err.message).join('; ')}` | ||
| : ''; | ||
| failures.push(`#${pr.number}: ${detail}${gateDetail}${metadataDetail}`); | ||
| core.error(`Failed to reconcile PR #${pr.number}: ${detail}${gateDetail}${metadataDetail}`); | ||
| } | ||
| } | ||
| if (failures.length > 0) { | ||
| core.setFailed(`Failed to reconcile ${failures.length} PR(s): ${failures.join('; ')}`); | ||
| } | ||