Repository navigation
feat(api): abort signal support for anthropic, anthropic-vertex, xai, minimax #68851
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 | ||
| stale_dry_run: | ||
| description: Set to false to let the stale clock write labels, comments, and closes | ||
| required: false | ||
| type: string | ||
| default: "true" | ||
| # 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] | ||
| # Fork pull_request_review runs get a read-only token and skip their label | ||
| # writes (issue #2046). The privileged follow-up for those runs lives in | ||
| # label-pr-review-state-fork-review-followup.yml: GitHub rejects a workflow | ||
| # that lists itself in workflow_run.workflows (issue #2086). | ||
| 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. The | ||
| // workflows listed in workflow_run trigger on pull_request, whose | ||
| // head_repository is the real fork, so the owner-and-branch query | ||
| // is safe here. The follow-up workflow's copy of this backfill | ||
| // matches by head branch, SHA, and base repository instead: its | ||
| // pull_request_review runs report the base repository as | ||
| // head_repository (issue #2133). | ||
| 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}`); | ||
| // Turn labels reconcile exclusively among themselves: exactly one | ||
| // can be true. has-conflicts is deliberately NOT in this group | ||
| // (issue #2089) — whether the branch has conflicts is an | ||
| // independent fact that can coexist with any turn. It is managed | ||
| // only by reconcileConflictFlag below: set on a known dirty read, | ||
| // removed on a known read that is not dirty, untouched while | ||
| // mergeability is unknown. | ||
| const stateLabels = [ | ||
| 'awaiting-author', | ||
| 'awaiting-coderabbit', | ||
| 'awaiting-ready', | ||
| 'awaiting-maintainer', | ||
| 'awaiting-review', // Legacy label removed during reconciliation. | ||
| ]; | ||
| 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 (turn labels) from a PR, optionally keeping | ||
| // one desired label. Callers skip this entirely while mergeability | ||
| // is unknown (see the hoisted check below), so no keep-list is | ||
| // needed. has-conflicts lives outside this group: it is the | ||
| // independent conflict flag managed by reconcileConflictFlag, so | ||
| // turn-label reconciliation — including the outer failure cleanup | ||
| // — never strips or sets it. | ||
| // Stale-clock labels are owned by the stale step below and are never | ||
| // touched here: stripping one on a transient CI or mergeability flap | ||
| // would erase a live warning and restart its close grace (issue #2076). | ||
| // 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 (labelErrors.length > 0) { | ||
| throw new Error(`Could not reconcile labels: ${labelErrors.map(err => err.message).join('; ')}`); | ||
| } | ||
| } | ||
| // Reconciles the independent has-conflicts flag (issue #2089). | ||
| // `present` is a mergeability fact, so callers pass a known read | ||
| // only: true for a known dirty read, false for a known read that | ||
| // is not dirty. While mergeability is unknown no caller runs, so | ||
| // the flag freezes together with the turn labels. A conflicted PR | ||
| // must never sit in the maintainer queue, so every conflict | ||
| // branch pairs this call with reconcileLabels(pr, 'awaiting-author'). | ||
| async function reconcileConflictFlag(pr, present) { | ||
| if (isReadOnlyRun && isForkPR(pr)) { | ||
| core.info(`PR #${pr.number}: fork PR on a read-only run — skipping label mutation`); | ||
| return; | ||
| } | ||
| const hasLabel = pr.labels.some(label => label.name === 'has-conflicts'); | ||
| if (present === hasLabel) return; | ||
| try { | ||
| if (present) { | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: pr.number, labels: ['has-conflicts'], | ||
| }); | ||
| if (!pr.labels.some(label => label.name === 'has-conflicts')) { | ||
| pr.labels.push({ name: 'has-conflicts' }); | ||
| } | ||
| } else { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: 'has-conflicts', | ||
| }); | ||
| pr.labels = pr.labels.filter(label => label.name !== 'has-conflicts'); | ||
| } | ||
| } catch (err) { | ||
| if (err.status === 404 && !present) { | ||
| pr.labels = pr.labels.filter(label => label.name !== 'has-conflicts'); | ||
| return; | ||
| } | ||
| // The flag already decided this run's label state, so a | ||
| // failed flag write must fail closed: mark the wrapper | ||
| // error so the outer cleanup keeps the current state | ||
| // labels exactly like the unknown-mergeability freeze | ||
| // instead of stripping them. | ||
| const error = new Error(`Could not reconcile has-conflicts label: ${err.message}`); | ||
| error.preserveStateLabels = true; | ||
| throw error; | ||
| } | ||
| } | ||
| 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, | ||
| // and give GitHub a bounded chance to finish computing it: retry | ||
| // the read up to 3 times with a short wait between reads before | ||
| // applying the unknown handling below (issue #2089). The wait is | ||
| // env-overridable so the test suite can retry without real waits. | ||
| const mergeabilityRetryDelayMs = Number(process.env.MERGEABILITY_RETRY_DELAY_MS ?? 5000); | ||
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); | ||
| let prDetail = pr; | ||
| for ( | ||
| let attempt = 1; | ||
| attempt <= 3 && | ||
| (prDetail.mergeable === null || prDetail.mergeable_state === 'unknown'); | ||
| attempt++ | ||
| ) { | ||
| await sleep(mergeabilityRetryDelayMs); | ||
| core.info(`PR #${pr.number}: mergeability unknown — retrying read (${attempt}/3)`); | ||
| try { | ||
| const { data: retryDetail } = await github.rest.pulls.get({ | ||
| owner, repo, pull_number: pr.number, | ||
| }); | ||
| prDetail = retryDetail; | ||
| } catch (error) { | ||
| // The loop condition guarantees mergeability was still | ||
| // unknown when this retry started. A failed retry (for | ||
| // example HTTP 503) adds no new mergeability answer, so | ||
| // the outer cleanup must keep the current state labels | ||
| // exactly like the still-unknown path below (issue | ||
| // #2089). Gate invalidation and failure reporting still | ||
| // happen in the outer catch. | ||
| error.preserveStateLabels = true; | ||
| throw error; | ||
| } | ||
| } | ||
| if (prDetail.mergeable === false && prDetail.mergeable_state === 'dirty') { | ||
| core.info( | ||
| `PR #${pr.number}: has merge conflicts — ` + | ||
| 'labeling has-conflicts and awaiting-author' | ||
| ); | ||
| await updateReviewGate(pr, 'conflict', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| // Conflicts are an independent flag, and a conflicted PR | ||
| // always owes the author action (issue #2089): set the | ||
| // flag and force the awaiting-author turn label — never | ||
| // has-conflicts alone, and never a maintainer-queue label. | ||
| await reconcileConflictFlag(pr, true); | ||
| await reconcileLabels(pr, 'awaiting-author'); | ||
| await reconcileCommunityApproval(pr, 'remove'); | ||
| await updateReviewGuide(pr, 'conflict', existingGuide); | ||
| continue; | ||
| } | ||
| // Unknown mergeability is handled once here, ahead of every | ||
| // branch that reconciles labels (issues #2045 and #2089). If the | ||
| // reads above still report `mergeable: null` / | ||
| // `mergeable_state: "unknown"`, GitHub has not finished computing | ||
| // the answer, so it says nothing about conflicts and nothing about | ||
| // whose turn it is. This run then changes NO state label for the | ||
| // PR: every state-label reconcile below is skipped, and the | ||
| // has-conflicts flag freezes with the turn labels. Acting on | ||
| // unknown previously let the normal reconcile swap labels (for | ||
| // example has-conflicts to awaiting-author and back on the next | ||
| // run's `dirty` read), which flapped labels on unchanged PRs. | ||
| // Known-dirty never reaches here (the conflict check above | ||
| // handles it first); a known read that is not dirty removes | ||
| // the flag in the branches below. The awaiting-maintainer | ||
| // branch below still refetches mergeability before the | ||
| // maintainer handoff, because a push between this fetch and | ||
| // labeling can change the answer; a known read there | ||
| // re-enables the final reconcile. | ||
| let mergeabilityUnknown = | ||
| prDetail.mergeable === null || prDetail.mergeable_state === 'unknown'; | ||
| if (mergeabilityUnknown) { | ||
| core.info( | ||
| `PR #${pr.number}: mergeability still unknown after retries — ` + | ||
| 'leaving state labels unchanged this run' | ||
| ); | ||
| } | ||
| // 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 (issues #2025 and #2089): 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. Unknown mergeability changes no state | ||
| // label in this run, so this branch keeps every existing state | ||
| // label (the has-conflicts flag included) instead of stripping | ||
| // them; a later run with a known read reconciles them. On a | ||
| // known read the branch got here only because the PR is not | ||
| // dirty (the conflict check handles dirty first), so the | ||
| // conflict flag is removed along with the turn labels. | ||
| if (ciPending || ciFailed) { | ||
| core.info( | ||
| `PR #${pr.number}: CI ${ciPending ? 'pending' : 'failed'} — ` + | ||
| (mergeabilityUnknown | ||
| ? 'mergeability unknown; leaving state labels unchanged' | ||
| : 'stripping state labels') | ||
| ); | ||
| await updateReviewGate(pr, ciPending ? 'ci-pending' : 'ci-failed', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| if (!mergeabilityUnknown) { | ||
| await reconcileConflictFlag(pr, false); | ||
| await reconcileLabels(pr, 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 and awaiting-author' | ||
| ); | ||
| await updateReviewGate(pr, 'conflict', false); | ||
| await setCodeRabbitReviewActive(pr, false); | ||
| // Same invariant as the first conflict branch (issue | ||
| // #2089): the flag plus the awaiting-author turn label, | ||
| // so a conflicted PR can never land in the maintainer | ||
| // queue this run was about to grant. | ||
| await reconcileConflictFlag(pr, true); | ||
| await reconcileLabels(pr, 'awaiting-author'); | ||
| 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; | ||
| } | ||
| // The refetch above settled mergeability with a fresh known read, | ||
| // so the final state-label reconcile below may run even when the | ||
| // shard's first reads were unknown (issue #2089). | ||
| mergeabilityUnknown = false; | ||
| communityDecision = headChanged | ||
| ? 'remove' | ||
| : communityApprovalFresh | ||
| ? 'add' | ||
| : communityApprovalInvalidated | ||
| ? 'remove' | ||
| : communityPermissionUncertain | ||
| ? 'preserve' | ||
| : 'remove'; | ||
| } | ||
| core.info( | ||
| `PR #${pr.number}: CI passing, reviews=${latest.size}, ` + | ||
| `mergeable=${prDetail.mergeable}, mergeable_state=${prDetail.mergeable_state}, ` + | ||
| `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); | ||
| } | ||
| if (!mergeabilityUnknown) { | ||
| // The read that re-enabled this reconcile is known and not | ||
| // dirty (the conflict branches handle dirty first), so the | ||
| // conflict flag comes off whenever the turn labels reconcile. | ||
| await reconcileConflictFlag(pr, false); | ||
| 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('; ')}`); | ||
| } | ||
| - name: Reconcile stale PR clock | ||
| if: ${{ !cancelled() }} | ||
| 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}`); | ||
| // Issue #2076: this workflow owns the stale clock (policy C). Only | ||
| // author activity resets it: the author's latest commit, the | ||
| // author's latest comment or review, and ready_for_review events | ||
| // the author marked. Bot comments and label flips (including this | ||
| // workflow's own review-status updates) never move the clock, so | ||
| // hourly reconciliation cannot keep an inactive PR open forever. | ||
| // Bot-authored PRs (user.type 'Bot') sit outside the clock | ||
| // entirely: their commits are commonly applied by a different | ||
| // bot login, so the commit clock cannot see their real activity. | ||
| const dayMs = 24 * 60 * 60 * 1000; | ||
| const closeGraceDays = 7; | ||
| const exemptLabels = new Set(['do-not-close', 'pinned', 'work-in-progress']); | ||
| const stalePolicies = { | ||
| inactive: { | ||
| label: 'stale-inactive', | ||
| staleDays: 60, | ||
| staleMessage: | ||
| 'This PR has had no activity for 60 days and will be automatically ' + | ||
| 'closed in 7 days unless there is new activity. If you\'d like to ' + | ||
| 'continue, please rebase and leave a comment.', | ||
| closeMessage: | ||
| 'Closing due to 60+ days of inactivity. Feel free to reopen if ' + | ||
| 'you\'d like to resume this work.', | ||
| }, | ||
| awaitingAuthor: { | ||
| label: 'stale-awaiting-author', | ||
| staleDays: 14, | ||
| staleMessage: | ||
| 'This PR has been awaiting author changes for 14 days and will be ' + | ||
| 'automatically closed in 7 days. Please address the review comments ' + | ||
| 'or leave a comment if you need more time.', | ||
| closeMessage: | ||
| 'Closing due to author inactivity after requested changes. Feel free ' + | ||
| 'to reopen once the requested changes have been addressed.', | ||
| }, | ||
| }; | ||
| const staleLabels = [stalePolicies.inactive.label, stalePolicies.awaitingAuthor.label]; | ||
| const minStaleDays = stalePolicies.awaitingAuthor.staleDays; | ||
| // The clock starts in observation mode. An operator must pass | ||
| // stale_dry_run=false on a workflow_dispatch, or set the | ||
| // STALE_DRY_RUN repository variable to false, before anything is | ||
| // written. With neither override present, every run — schedule | ||
| // included — only logs proposed actions. | ||
| const dryRun = String(process.env.STALE_DRY_RUN ?? 'true').trim().toLowerCase() !== 'false'; | ||
| const { data: pr } = await github.rest.pulls.get({ | ||
| owner, repo, pull_number: Number(process.env.PR_NUMBER), | ||
| }); | ||
| if (pr.state !== 'open') { | ||
| core.info(`PR #${pr.number}: not open. Skipping stale reconciliation`); | ||
| return; | ||
| } | ||
| // Fork pull_request_review runs hold a read-only token; sweep and | ||
| // pull_request_target runs reconcile fork PRs normally. | ||
| const isReadOnlyRun = context.eventName === 'pull_request_review' && | ||
| Boolean(pr.head?.repo?.full_name) && | ||
| pr.head.repo.full_name !== pr.base?.repo?.full_name; | ||
| if (isReadOnlyRun) { | ||
| core.info(`PR #${pr.number}: fork PR on a read-only run. Skipping stale reconciliation`); | ||
| return; | ||
| } | ||
| const now = Date.now(); | ||
| try { | ||
| const currentLabels = new Set(pr.labels.map(label => label.name)); | ||
| const presentStaleLabels = staleLabels.filter(label => currentLabels.has(label)); | ||
| const exempt = [...exemptLabels].some(label => currentLabels.has(label)); | ||
| // The awaiting-author policy also applies while its stale label | ||
| // is present without awaiting-author: a transient CI, conflict, | ||
| // or mergeability flap strips awaiting-author and replaces it | ||
| // with no state label, and the preserved warning must still | ||
| // close on its promised clock (issue #2076). The policy stops | ||
| // only on a definitive maintainer handoff: awaiting-maintainer | ||
| // means the reconcile step moved the PR to the maintainer | ||
| // queue, so the author no longer owes changes. There the | ||
| // 14-day clock is retired, the cleanup below removes the stale | ||
| // warning, and the ordinary 60/67-day inactive clock governs. | ||
| // Tradeoff: a blocker cleared without author action now | ||
| // survives to 67 days instead of 21. | ||
| const awaitingClockApplies = currentLabels.has('awaiting-author') || | ||
| (currentLabels.has(stalePolicies.awaitingAuthor.label) && | ||
| !currentLabels.has('awaiting-maintainer')); | ||
| const policy = awaitingClockApplies | ||
| ? stalePolicies.awaitingAuthor | ||
| : stalePolicies.inactive; | ||
| const closeAfterDays = policy.staleDays + closeGraceDays; | ||
| // The policy window covers both decision thresholds: activity | ||
| // older than this can affect neither mark-stale nor close. | ||
| const windowStartMs = now - closeAfterDays * dayMs; | ||
| const authorLogin = pr.user?.login?.toLowerCase() ?? null; | ||
| // GitHub sets user.type 'Bot' on automated accounts (for | ||
| // example zoomote[bot], whose commits are applied by a | ||
| // different login such as roomote). No mapping from a bot to | ||
| // its owner login exists, so the commit clock cannot see real | ||
| // activity on these PRs. Exempt them from stale marking and | ||
| // closure; the cleanup below still removes leftover stale | ||
| // labels. user.type is authoritative. No [bot] suffix | ||
| // heuristic: suffix matching would miss custom bot names and | ||
| // misclassify human logins. A PR with no user keeps the | ||
| // ordinary human behavior. | ||
| const botAuthor = pr.user?.type === 'Bot'; | ||
| core.info(`PR #${pr.number}: stale clock dry run: ${dryRun}`); | ||
| // Cheap pre-check: opening the PR is author activity, so | ||
| // created_at floors the author clock. A nonexempt PR with no | ||
| // stale label that is younger than its policy's stale threshold | ||
| // can need no action, so it costs no further API work. | ||
| const prAgeDays = (now - Date.parse(pr.created_at ?? '')) / dayMs; | ||
| if (!exempt && presentStaleLabels.length === 0 && | ||
| !Number.isNaN(prAgeDays) && prAgeDays < policy.staleDays) { | ||
| core.info( | ||
| `PR #${pr.number}: PR age ${Math.floor(Math.max(prAgeDays, 0))} days is below ` + | ||
| `the ${policy.staleDays}-day stale threshold and no stale label is present. Skipping` | ||
| ); | ||
| return; | ||
| } | ||
| // Opening the PR is author activity, so created_at is the clock | ||
| // floor. Anything not attributable to the author is ignored. | ||
| let lastActivity = Date.parse(pr.created_at ?? ''); | ||
| if (Number.isNaN(lastActivity)) lastActivity = 0; | ||
| const consider = (value) => { | ||
| const at = Date.parse(value ?? ''); | ||
| if (!Number.isNaN(at) && at > lastActivity) lastActivity = at; | ||
| }; | ||
| const removeStaleLabel = async (label) => { | ||
| if (!currentLabels.has(label)) return; | ||
| if (dryRun) { | ||
| core.info(`PR #${pr.number}: [dry-run] would remove label ${label}`); | ||
| return; | ||
| } | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: pr.number, name: label, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
| currentLabels.delete(label); | ||
| }; | ||
| const addStaleLabel = async (label) => { | ||
| if (dryRun) { | ||
| core.info(`PR #${pr.number}: [dry-run] would add label ${label}`); | ||
| return; | ||
| } | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: pr.number, labels: [label], | ||
| }); | ||
| currentLabels.add(label); | ||
| }; | ||
| const postComment = async (body) => { | ||
| if (dryRun) { | ||
| core.info(`PR #${pr.number}: [dry-run] would comment: ${body}`); | ||
| return; | ||
| } | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number: pr.number, body, | ||
| }); | ||
| }; | ||
| const closePullRequest = async () => { | ||
| if (dryRun) { | ||
| core.info(`PR #${pr.number}: [dry-run] would close the PR`); | ||
| return; | ||
| } | ||
| await github.rest.pulls.update({ | ||
| owner, repo, pull_number: pr.number, state: 'closed', | ||
| }); | ||
| }; | ||
| async function applyDecision(decision) { | ||
| // Clock skew can date activity in the future; clamp it so | ||
| // the log and the reason show a present-time floor. | ||
| if (lastActivity > now) lastActivity = now; | ||
| core.info( | ||
| `PR #${pr.number}: last author activity ${new Date(lastActivity).toISOString()} ` + | ||
| `(${decision.reason}) → ${decision.action}` | ||
| ); | ||
| if (decision.action === 'close') { | ||
| // Close first. A failed close leaves no close comment | ||
| // behind, so the next sweep cannot post a duplicate | ||
| // close notice. | ||
| await closePullRequest(); | ||
| await postComment(decision.policy.closeMessage); | ||
| } else if (decision.action === 'mark-stale') { | ||
| await removeStaleLabel(staleLabels.find(label => label !== decision.policy.label)); | ||
| if (!currentLabels.has(decision.policy.label)) { | ||
| // Warn before labeling. A failed warning leaves no | ||
| // label and no close timer; a failed label write only | ||
| // reposts the warning on the next sweep. | ||
| await postComment(decision.policy.staleMessage); | ||
| await addStaleLabel(decision.policy.label); | ||
| } | ||
| } else if (decision.action === 'clear') { | ||
| for (const label of decision.clearLabels ?? staleLabels) { | ||
| await removeStaleLabel(label); | ||
| } | ||
| } | ||
| } | ||
| // Pure decision over the author clock. Closing needs three | ||
| // gates at once: the author clock past the close threshold, the | ||
| // policy stale label currently present, and its latest labeled | ||
| // timeline event at least closeGraceDays old. An unlabeled PR | ||
| // and a fresh warning never close, whatever the clock says: an | ||
| // unlabeled PR is only eligible for a warning now, closure | ||
| // becomes possible no earlier than closeGraceDays after that | ||
| // warning label is applied, and only if the policy conditions | ||
| // still hold then. A dry run therefore never proposes an | ||
| // immediate close for an unlabeled PR. | ||
| function decideStaleAction(authorAgeDays, applicableLabeledAgeDays) { | ||
| const age = Math.floor(Math.max(authorAgeDays, 0)); | ||
| const applicablePresent = currentLabels.has(policy.label); | ||
| if (botAuthor) { | ||
| return presentStaleLabels.length > 0 | ||
| ? { action: 'clear', clearLabels: presentStaleLabels, reason: 'bot-authored PR' } | ||
| : { action: 'none', reason: 'bot-authored PR' }; | ||
| } | ||
| if (exempt) { | ||
| return presentStaleLabels.length > 0 | ||
| ? { action: 'clear', clearLabels: presentStaleLabels, reason: 'exempt label present' } | ||
| : { action: 'none', reason: 'exempt label present' }; | ||
| } | ||
| const labeledOldEnough = applicableLabeledAgeDays !== null && | ||
| applicableLabeledAgeDays >= closeGraceDays; | ||
| if (authorAgeDays >= closeAfterDays && applicablePresent && labeledOldEnough) { | ||
| return { | ||
| action: 'close', | ||
| policy, | ||
| reason: `author inactive for ${age} days; ` + | ||
| `${policy.label} applied ${Math.floor(applicableLabeledAgeDays)} days ago`, | ||
| }; | ||
| } | ||
| if (authorAgeDays >= policy.staleDays) { | ||
| return applicablePresent | ||
| ? { action: 'none', reason: `author inactive for ${age} days; ${policy.label} warning is inside its grace window` } | ||
| : { action: 'mark-stale', policy, reason: `author inactive for ${age} days` }; | ||
| } | ||
| if (authorAgeDays < minStaleDays) { | ||
| return presentStaleLabels.length > 0 | ||
| ? { action: 'clear', clearLabels: presentStaleLabels, reason: `author active ${age} days ago` } | ||
| : { action: 'none', reason: `author active ${age} days ago` }; | ||
| } | ||
| // Here the author clock sits between the two thresholds under | ||
| // the inactive policy, so no close clock is live. Clear | ||
| // whatever stale labels the PR carries: stale-inactive is | ||
| // leftover from an earlier inactive cycle, and | ||
| // stale-awaiting-author can only be a warning retired by the | ||
| // maintainer handoff above. | ||
| const leftoverStaleLabels = staleLabels.filter(label => currentLabels.has(label)); | ||
| const reason = `author inactive for ${age} days; ` + | ||
| `${stalePolicies.inactive.label} applies at 60 days`; | ||
| return leftoverStaleLabels.length > 0 | ||
| ? { action: 'clear', clearLabels: leftoverStaleLabels, reason } | ||
| : { action: 'none', reason }; | ||
| } | ||
| // Ordering differs by endpoint. pulls.listCommits returns the | ||
| // PR commits oldest-first and caps the whole list at 250, so | ||
| // the walk must reach the last page or it can miss the newest | ||
| // commits; three pages cover the cap. Past the cap the PR | ||
| // endpoint cannot return the newest commits at all, so those | ||
| // PRs switch to the repository commits endpoint from the head | ||
| // SHA: newest-first, no 250-commit cap, and a server-side | ||
| // since filter. That endpoint also walks main-line history | ||
| // merged into the branch; attribution below still counts only | ||
| // PR-author commits, so the worst case is a recent author | ||
| // commit on main keeping the PR open longer. | ||
| async function recentCommits() { | ||
| const perPage = 100; | ||
| // Bounded walk: maxPages keeps the loop finite when commit | ||
| // dates are missing or badly skewed. | ||
| const maxPages = 10; | ||
| let useHeadListing = (pr.commits ?? 0) > 250; | ||
| const commits = []; | ||
| for (let page = 1; page <= maxPages; page++) { | ||
| let data; | ||
| if (useHeadListing) { | ||
| try { | ||
| ({ data } = await github.rest.repos.listCommits({ | ||
| owner, repo, sha: pr.head.sha, per_page: perPage, page, | ||
| since: new Date(windowStartMs).toISOString(), | ||
| })); | ||
| } catch (error) { | ||
| // A fork head SHA may not resolve through the base | ||
| // repository endpoint. Fall back to the PR endpoint | ||
| // and its 250-commit truncation instead of failing | ||
| // the sweep; the truncation can hide a newest commit | ||
| // on PRs above the cap, so say so in the log. | ||
| core.warning( | ||
| `PR #${pr.number}: head commit listing failed (${error.message}); ` + | ||
| `falling back to the PR commit endpoint (250-commit cap)` | ||
| ); | ||
| useHeadListing = false; | ||
| } | ||
| } | ||
| if (!data) { | ||
| ({ data } = await github.rest.pulls.listCommits({ | ||
| owner, repo, pull_number: pr.number, per_page: perPage, page, | ||
| })); | ||
| } | ||
| commits.push(...data); | ||
| if (data.length < perPage) break; | ||
| if (useHeadListing) { | ||
| // Newest-first: once a page dips past the policy | ||
| // window, older pages cannot hold author activity | ||
| // that changes a decision. | ||
| const oldestDate = data.reduce((oldest, commit) => { | ||
| const at = Date.parse(commit.commit?.committer?.date ?? commit.commit?.author?.date ?? ''); | ||
| return !Number.isNaN(at) && (oldest === null || at < oldest) ? at : oldest; | ||
| }, null); | ||
| if (oldestDate !== null && oldestDate < windowStartMs) break; | ||
| } | ||
| // Oldest-first PR listing: every later page is newer, | ||
| // so no date-based break is safe here; the 250-commit | ||
| // cap bounds the walk at three pages. | ||
| } | ||
| return commits; | ||
| } | ||
| let applicableLabeledAgeDays = null; | ||
| let skipRemainingScans = false; | ||
| if (!exempt && !botAuthor) { | ||
| // Comments first: the cheapest recency source. The policy | ||
| // window is the server-side since filter. | ||
| const comments = await github.paginate(github.rest.issues.listComments, { | ||
| owner, repo, issue_number: pr.number, per_page: 100, | ||
| since: new Date(windowStartMs).toISOString(), | ||
| }); | ||
| if (authorLogin !== null) { | ||
| for (const comment of comments) { | ||
| if (comment.user?.login?.toLowerCase() !== authorLogin) continue; | ||
| // since filters on the update time, so an author edit | ||
| // counts as activity too. | ||
| consider(comment.updated_at ?? comment.created_at); | ||
| consider(comment.created_at); | ||
| } | ||
| } | ||
| const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { | ||
| owner, repo, issue_number: pr.number, per_page: 100, | ||
| }); | ||
| let applicableLabeledAt = null; | ||
| for (const event of timeline) { | ||
| if (event.event === 'labeled') { | ||
| if (event.label?.name !== policy.label) continue; | ||
| const at = Date.parse(event.created_at ?? ''); | ||
| if (Number.isNaN(at)) continue; | ||
| // The latest labeled event wins, so a removal and re-add | ||
| // or a policy swap restarts the close grace. | ||
| if (applicableLabeledAt === null || at > applicableLabeledAt) applicableLabeledAt = at; | ||
| } else if (event.event === 'ready_for_review' && authorLogin !== null) { | ||
| // Only the author marking the PR ready moves the clock. | ||
| // A maintainer readying a draft is not author activity. | ||
| if (event.actor?.login?.toLowerCase() !== authorLogin) continue; | ||
| consider(event.created_at); | ||
| } | ||
| } | ||
| applicableLabeledAgeDays = applicableLabeledAt === null | ||
| ? null | ||
| : (now - applicableLabeledAt) / dayMs; | ||
| // Seen sources bound the clock: unseen commits or reviews can | ||
| // only make it fresher. Inside the 14-day awaiting-author | ||
| // window the only possible action is clearing leftover stale | ||
| // labels, and that needs no further scans, so skip the commit | ||
| // and review work. | ||
| skipRemainingScans = (now - lastActivity) / dayMs < minStaleDays; | ||
| if (!skipRemainingScans && authorLogin !== null) { | ||
| for (const commit of await recentCommits()) { | ||
| const gitAuthor = commit.author?.login?.toLowerCase() ?? null; | ||
| const gitCommitter = commit.committer?.login?.toLowerCase() ?? null; | ||
| if (gitCommitter !== null && gitCommitter === authorLogin) { | ||
| // The author applied the commit (push, rebase, amend, | ||
| // web edit). The committer date is the moment of that | ||
| // activity; the author date covers skewed clocks. | ||
| consider(commit.commit?.committer?.date); | ||
| consider(commit.commit?.author?.date); | ||
| } else if (gitAuthor !== null && gitAuthor === authorLogin) { | ||
| // The author wrote the patch but someone else applied | ||
| // it. Only the author date counts, so a maintainer | ||
| // rebase never resets the clock. | ||
| consider(commit.commit?.author?.date); | ||
| } | ||
| // Commits with no linked login on either side never | ||
| // count. A contributor pushing from an unlinked email | ||
| // address stays invisible to the commit clock; comments | ||
| // and reviews still count. Commits by other users never | ||
| // count either. | ||
| } | ||
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | ||
| owner, repo, pull_number: pr.number, per_page: 100, | ||
| }); | ||
| for (const review of reviews) { | ||
| if (review.user?.login?.toLowerCase() !== authorLogin) continue; | ||
| consider(review.submitted_at); | ||
| } | ||
| } | ||
| } | ||
| const authorAgeDays = (now - lastActivity) / dayMs; | ||
| const decision = decideStaleAction(authorAgeDays, applicableLabeledAgeDays); | ||
| await applyDecision(decision); | ||
| } catch (error) { | ||
| core.setFailed(`Failed to reconcile stale state for PR #${pr.number}: ${error.message}`); | ||
| } | ||
| env: | ||
| # The clock starts in observation mode: writes need an explicit | ||
| # stale_dry_run=false through the workflow_dispatch input, or the | ||
| # STALE_DRY_RUN repository variable set to false. The dispatch | ||
| # input wins over the repository variable when both are set. | ||
| STALE_DRY_RUN: ${{ github.event.inputs.stale_dry_run || vars.STALE_DRY_RUN || 'true' }} | ||