Skip to content

feat(api): abort signal support for anthropic, anthropic-vertex, xai, minimax #68851

feat(api): abort signal support for anthropic, anthropic-vertex, xai, minimax

feat(api): abort signal support for anthropic, anthropic-vertex, xai, minimax #68851

name: Label PR review state

Check warning on line 1 in .github/workflows/label-pr-review-state.yml

View workflow run for this annotation

GitHub Actions / Label PR review state

Workflow execution policy warning (evaluate mode)

On December 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
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' }}