From 96b21918bfee490f5e0a49ff3a2804544f749f5b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:04:50 +0200 Subject: [PATCH 1/4] chore: gate maintainer review on automated readiness --- .coderabbit.yaml | 28 ++- .github/scripts/pr-readiness.cjs | 86 +++++++ .github/scripts/pr-readiness.test.cjs | 74 ++++++ .github/workflows/pr-readiness.yml | 214 ++++++++++++++++++ .../2026-08-02-pr-readiness-gate-design.md | 26 +++ 5 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/pr-readiness.cjs create mode 100644 .github/scripts/pr-readiness.test.cjs create mode 100644 .github/workflows/pr-readiness.yml create mode 100644 docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ab02b0265..d97f9c9d5 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -11,15 +11,41 @@ tone_instructions: >- reviews: profile: assertive + request_changes_workflow: true + review_status: true high_level_summary: true auto_review: enabled: true - drafts: false + drafts: true # Default branch (main) is included automatically; these are additional # base branches (anchored regex). base_branches: - "^dev$" - "^preview$" + pre_merge_checks: + override_requested_reviewers_only: true + description: + mode: error + issue_assessment: + mode: error + custom_checks: + - name: Regression evidence + mode: error + instructions: >- + Fail when runtime or dashboard behavior changes without a focused + regression test, unless the PR gives a technically credible reason + automated coverage is impossible and supplies concrete manual evidence. + - name: Scope discipline + mode: error + instructions: >- + Fail when the PR includes unrelated cleanup, broad formatting churn, + accidental generated files, or lockfile changes unrelated to the + stated issue and implementation. + - name: Validation evidence + mode: error + instructions: >- + Fail when validation is described only as "tested", "CI", or another + unverifiable claim. Require named commands or checks and their results. path_instructions: - path: "src/**" instructions: >- diff --git a/.github/scripts/pr-readiness.cjs b/.github/scripts/pr-readiness.cjs new file mode 100644 index 000000000..843fd2daf --- /dev/null +++ b/.github/scripts/pr-readiness.cjs @@ -0,0 +1,86 @@ +"use strict"; + +const ACCEPTABLE_CONCLUSIONS = new Set(["success", "neutral", "skipped"]); +const BLOCKING_CONCLUSIONS = new Set([ + "failure", + "cancelled", + "timed_out", + "action_required", + "stale", + "startup_failure", +]); +const IGNORED_NAMES = new Set([ + "PR readiness / reconcile", + "PR readiness", +]); + +function normalizeName(value) { + return String(value || "").trim(); +} + +function classifyStatuses(statuses) { + const pending = []; + const failed = []; + let observed = 0; + + for (const status of statuses || []) { + const name = normalizeName(status.context); + if (!name || IGNORED_NAMES.has(name)) continue; + observed += 1; + if (status.state === "pending") pending.push(name); + else if (status.state !== "success") failed.push(name); + } + + return { pending, failed, observed }; +} + +function classifyCheckRuns(checkRuns) { + const pending = []; + const failed = []; + let observed = 0; + + for (const check of checkRuns || []) { + const name = normalizeName(check.name); + if (!name || IGNORED_NAMES.has(name)) continue; + observed += 1; + if (check.status !== "completed") { + pending.push(name); + continue; + } + const conclusion = check.conclusion || ""; + if (BLOCKING_CONCLUSIONS.has(conclusion)) failed.push(name); + else if (!ACCEPTABLE_CONCLUSIONS.has(conclusion)) pending.push(name); + } + + return { pending, failed, observed }; +} + +function assessReadiness({ admissionPassed, statuses = [], checkRuns = [] }) { + if (!admissionPassed) { + return { + state: "author_action", + failed: ["PR admission"], + pending: [], + }; + } + + const statusResult = classifyStatuses(statuses); + const checkResult = classifyCheckRuns(checkRuns); + const failed = [...new Set([...statusResult.failed, ...checkResult.failed])]; + const pending = [...new Set([...statusResult.pending, ...checkResult.pending])]; + const observed = statusResult.observed + checkResult.observed; + + if (failed.length > 0) return { state: "author_action", failed, pending }; + if (pending.length > 0 || observed === 0) { + return { state: "validating", failed: [], pending }; + } + return { state: "maintainer", failed: [], pending: [] }; +} + +module.exports = { + ACCEPTABLE_CONCLUSIONS, + BLOCKING_CONCLUSIONS, + assessReadiness, + classifyCheckRuns, + classifyStatuses, +}; diff --git a/.github/scripts/pr-readiness.test.cjs b/.github/scripts/pr-readiness.test.cjs new file mode 100644 index 000000000..e4db0d2e9 --- /dev/null +++ b/.github/scripts/pr-readiness.test.cjs @@ -0,0 +1,74 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { assessReadiness, classifyCheckRuns } = require("./pr-readiness.cjs"); + +describe("assessReadiness", () => { + it("keeps failed admission in author-action state", () => { + assert.deepEqual( + assessReadiness({ admissionPassed: false }), + { state: "author_action", failed: ["PR admission"], pending: [] }, + ); + }); + + it("keeps PR validating while checks are pending", () => { + const result = assessReadiness({ + admissionPassed: true, + statuses: [{ context: "CodeRabbit", state: "pending" }], + checkRuns: [{ name: "Cross-platform CI", status: "in_progress" }], + }); + assert.equal(result.state, "validating"); + assert.deepEqual(result.pending.sort(), ["CodeRabbit", "Cross-platform CI"]); + }); + + it("returns author action for failed status or check", () => { + const result = assessReadiness({ + admissionPassed: true, + statuses: [{ context: "CodeRabbit", state: "failure" }], + checkRuns: [{ name: "tests", status: "completed", conclusion: "success" }], + }); + assert.equal(result.state, "author_action"); + assert.deepEqual(result.failed, ["CodeRabbit"]); + }); + + it("returns maintainer only after every observed check passes", () => { + const result = assessReadiness({ + admissionPassed: true, + statuses: [{ context: "CodeRabbit", state: "success" }], + checkRuns: [ + { name: "tests", status: "completed", conclusion: "success" }, + { name: "docs", status: "completed", conclusion: "skipped" }, + ], + }); + assert.deepEqual(result, { state: "maintainer", failed: [], pending: [] }); + }); + + it("does not claim readiness when no checks were observed", () => { + assert.equal( + assessReadiness({ admissionPassed: true }).state, + "validating", + ); + }); + + it("ignores its own readiness check to avoid recursion", () => { + const result = assessReadiness({ + admissionPassed: true, + statuses: [{ context: "CodeRabbit", state: "success" }], + checkRuns: [ + { name: "PR readiness / reconcile", status: "in_progress" }, + ], + }); + assert.equal(result.state, "maintainer"); + }); +}); + +describe("classifyCheckRuns", () => { + it("treats action_required and timed_out as failures", () => { + const result = classifyCheckRuns([ + { name: "a", status: "completed", conclusion: "action_required" }, + { name: "b", status: "completed", conclusion: "timed_out" }, + ]); + assert.deepEqual(result.failed, ["a", "b"]); + }); +}); diff --git a/.github/workflows/pr-readiness.yml b/.github/workflows/pr-readiness.yml new file mode 100644 index 000000000..2631596cc --- /dev/null +++ b/.github/workflows/pr-readiness.yml @@ -0,0 +1,214 @@ +name: PR readiness + +on: + pull_request_target: + types: [opened, reopened, edited, synchronize, ready_for_review, converted_to_draft] + status: + check_run: + types: [created, rerequested, completed, requested_action] + schedule: + - cron: "7,22,37,52 * * * *" + +# Trusted default-branch code only. The PR head is never checked out or executed. +permissions: + contents: write + checks: read + statuses: read + issues: write + pull-requests: write + +concurrency: + group: pr-readiness-${{ github.event.pull_request.number || github.event.check_run.head_sha || github.event.sha || 'sweep' }} + cancel-in-progress: true + +jobs: + reconcile: + name: reconcile + if: github.event_name != 'check_run' || github.event.check_run.name != 'PR readiness / reconcile' + runs-on: ubuntu-latest + steps: + - name: Checkout trusted readiness script + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Reconcile review readiness + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const path = require("node:path"); + const { assessReadiness } = require( + path.join(process.cwd(), ".github", "scripts", "pr-readiness.cjs"), + ); + + const { owner, repo } = context.repo; + const marker = ""; + const managedLabels = [ + "awaiting-author", + "awaiting-maintainer", + "intake: validating", + "intake: auto-drafted", + ]; + + async function ensureLabel(name, color, description) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (error) { + if (error.status !== 404) throw error; + try { + await github.rest.issues.createLabel({ + owner, repo, name, color, description, + }); + } catch (createError) { + if (createError.status !== 422) throw createError; + } + } + } + + await ensureLabel("awaiting-author", "d93f0b", "Author action is required before maintainer review"); + await ensureLabel("awaiting-maintainer", "1d76db", "All automated gates passed; maintainer review is next"); + await ensureLabel("intake: validating", "fbca04", "Automated checks are still running"); + await ensureLabel("intake: auto-drafted", "c5def5", "Workflow converted this PR to draft and may restore it"); + + async function associatedPrNumbers(sha) { + if (!sha) return []; + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: sha, + }); + return response.data.filter((pr) => pr.state === "open").map((pr) => pr.number); + } + + let numbers = []; + if (context.eventName === "pull_request_target") { + numbers = [context.payload.pull_request.number]; + } else if (context.eventName === "status") { + numbers = await associatedPrNumbers(context.payload.sha); + } else if (context.eventName === "check_run") { + numbers = (context.payload.check_run.pull_requests || []).map((pr) => pr.number); + if (numbers.length === 0) { + numbers = await associatedPrNumbers(context.payload.check_run.head_sha); + } + } else { + const open = await github.paginate(github.rest.pulls.list, { + owner, repo, state: "open", per_page: 100, + }); + numbers = open.map((pr) => pr.number); + } + + numbers = [...new Set(numbers)]; + core.info(`Reconciling PRs: ${numbers.join(", ") || "none"}`); + + async function convertToDraft(nodeId) { + await github.graphql( + `mutation($id: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $id }) { + pullRequest { id isDraft } + } + }`, + { id: nodeId }, + ); + } + + async function markReady(nodeId) { + await github.graphql( + `mutation($id: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $id }) { + pullRequest { id isDraft } + } + }`, + { id: nodeId }, + ); + } + + for (const pull_number of numbers) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); + const labels = new Set(pr.labels.map((label) => label.name)); + + const combined = await github.rest.repos.getCombinedStatusForRef({ + owner, repo, ref: pr.head.sha, + }); + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, repo, ref: pr.head.sha, filter: "latest", per_page: 100, + }); + + const result = assessReadiness({ + admissionPassed: labels.has("intake: admitted"), + statuses: combined.data.statuses, + checkRuns, + }); + + async function add(name) { + if (labels.has(name)) return; + await github.rest.issues.addLabels({ + owner, repo, issue_number: pull_number, labels: [name], + }); + labels.add(name); + } + + async function remove(name) { + if (!labels.has(name)) return; + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pull_number, name, + }); + labels.delete(name); + } + + async function upsertComment(body) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pull_number, per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + } + } + + if (result.state === "maintainer") { + await remove("awaiting-author"); + await remove("intake: validating"); + await add("awaiting-maintainer"); + if (labels.has("intake: auto-drafted") && pr.draft) { + await markReady(pr.node_id); + } + await remove("intake: auto-drafted"); + await upsertComment( + `${marker}\n\n✅ **Automated review-readiness gates passed.**\n\nThis PR is now waiting on a maintainer. Passing these gates is not approval.`, + ); + continue; + } + + await remove("awaiting-maintainer"); + if (!pr.draft) { + try { + await convertToDraft(pr.node_id); + await add("intake: auto-drafted"); + } catch (error) { + core.warning(`Could not convert PR #${pull_number} to draft: ${error.message}`); + } + } + + if (result.state === "validating") { + await remove("awaiting-author"); + await add("intake: validating"); + const detail = result.pending.length > 0 + ? `Pending: ${result.pending.map((name) => `\`${name}\``).join(", ")}.` + : "No repository checks have reported yet."; + await upsertComment(`${marker}\n\n⏳ **Automated checks are still running.**\n\n${detail}`); + continue; + } + + await remove("intake: validating"); + await add("awaiting-author"); + await upsertComment( + `${marker}\n\n⚠️ **Author action is required.**\n\n` + + `Failing gates: ${result.failed.map((name) => `\`${name}\``).join(", ")}. ` + + "Fix the failures on this branch; maintainers are not expected to repair contributor branches.", + ); + core.setFailed(`PR #${pull_number} is waiting on author action: ${result.failed.join(", ")}`); + } diff --git a/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md b/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md new file mode 100644 index 000000000..a1a661686 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md @@ -0,0 +1,26 @@ +# PR readiness and CodeRabbit gate — Design + +**Stack:** 2/5, based on `agent/pr-contribution-firewall` + +## Goal + +Prevent human review from starting while admission, CI, or CodeRabbit is incomplete or failing. Keep pending automation separate from author neglect so stale automation never closes a PR merely because CI is still running. + +## States + +- `awaiting-author`: admission or an automated check failed. +- `intake: validating`: admission passed, but checks are pending or have not reported. +- `awaiting-maintainer`: every observed check and CodeRabbit status passed. +- `intake: auto-drafted`: the workflow owns the draft transition and may restore ready-for-review when gates pass. + +## Safety + +The workflow uses `pull_request_target` and default-branch scripts only. It never checks out or executes PR-head code. `status` and `check_run` events reconcile quickly; a 15-minute schedule repairs missed events. + +## CodeRabbit + +Enable request-changes workflow, review drafts, restrict pre-merge overrides to requested reviewers, and make regression evidence, scope discipline, validation evidence, linked-issue assessment, and description quality blocking checks. + +## Rollout + +Promote trusted files to the default branch, test on a synthetic external PR, then make the readiness and CodeRabbit checks required through repository settings. From 14417405c603213b96bc26732ebfc1cfae4e0bb7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:40:21 +0200 Subject: [PATCH 2/4] fix(ci): keep PRs validating while admission is still running --- .github/scripts/pr-readiness.cjs | 15 ++++++ .github/scripts/pr-readiness.test.cjs | 49 ++++++++++++++++++- .../2026-08-02-pr-readiness-gate-design.md | 2 + 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pr-readiness.cjs b/.github/scripts/pr-readiness.cjs index 843fd2daf..dca6d093e 100644 --- a/.github/scripts/pr-readiness.cjs +++ b/.github/scripts/pr-readiness.cjs @@ -55,8 +55,22 @@ function classifyCheckRuns(checkRuns) { return { pending, failed, observed }; } +function admissionCheckPending(checkRuns) { + const admission = (checkRuns || []).find( + (check) => normalizeName(check.name) === "PR admission / admission", + ); + return Boolean(admission && admission.status !== "completed"); +} + function assessReadiness({ admissionPassed, statuses = [], checkRuns = [] }) { if (!admissionPassed) { + if (admissionCheckPending(checkRuns)) { + return { + state: "validating", + failed: [], + pending: ["PR admission"], + }; + } return { state: "author_action", failed: ["PR admission"], @@ -80,6 +94,7 @@ function assessReadiness({ admissionPassed, statuses = [], checkRuns = [] }) { module.exports = { ACCEPTABLE_CONCLUSIONS, BLOCKING_CONCLUSIONS, + admissionCheckPending, assessReadiness, classifyCheckRuns, classifyStatuses, diff --git a/.github/scripts/pr-readiness.test.cjs b/.github/scripts/pr-readiness.test.cjs index e4db0d2e9..4431db73c 100644 --- a/.github/scripts/pr-readiness.test.cjs +++ b/.github/scripts/pr-readiness.test.cjs @@ -2,7 +2,11 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); -const { assessReadiness, classifyCheckRuns } = require("./pr-readiness.cjs"); +const { + admissionCheckPending, + assessReadiness, + classifyCheckRuns, +} = require("./pr-readiness.cjs"); describe("assessReadiness", () => { it("keeps failed admission in author-action state", () => { @@ -12,6 +16,25 @@ describe("assessReadiness", () => { ); }); + it("keeps PR validating while the admission check is still running", () => { + const result = assessReadiness({ + admissionPassed: false, + checkRuns: [{ name: "PR admission / admission", status: "in_progress" }], + }); + assert.equal(result.state, "validating"); + assert.deepEqual(result.pending, ["PR admission"]); + }); + + it("returns author action when admission completed with a failure", () => { + const result = assessReadiness({ + admissionPassed: false, + checkRuns: [ + { name: "PR admission / admission", status: "completed", conclusion: "failure" }, + ], + }); + assert.equal(result.state, "author_action"); + }); + it("keeps PR validating while checks are pending", () => { const result = assessReadiness({ admissionPassed: true, @@ -72,3 +95,27 @@ describe("classifyCheckRuns", () => { assert.deepEqual(result.failed, ["a", "b"]); }); }); + +describe("admissionCheckPending", () => { + it("treats an in-progress admission check as pending", () => { + assert.equal( + admissionCheckPending([ + { name: "PR admission / admission", status: "in_progress" }, + ]), + true, + ); + }); + + it("treats a completed admission check as not pending", () => { + assert.equal( + admissionCheckPending([ + { name: "PR admission / admission", status: "completed", conclusion: "failure" }, + ]), + false, + ); + }); + + it("is false when no admission check is present", () => { + assert.equal(admissionCheckPending([]), false); + }); +}); diff --git a/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md b/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md index a1a661686..535b60cae 100644 --- a/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md +++ b/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md @@ -13,6 +13,8 @@ Prevent human review from starting while admission, CI, or CodeRabbit is incompl - `awaiting-maintainer`: every observed check and CodeRabbit status passed. - `intake: auto-drafted`: the workflow owns the draft transition and may restore ready-for-review when gates pass. +While the admission check is still running, a PR without `intake: admitted` is classified as `intake: validating` rather than `awaiting-author`, so concurrent admission and readiness runs cannot mislabel a compliant PR or start its inactivity timer. + ## Safety The workflow uses `pull_request_target` and default-branch scripts only. It never checks out or executes PR-head code. `status` and `check_run` events reconcile quickly; a 15-minute schedule repairs missed events. From 53ed63a1c851146d302aa71f074ad02f532c9cd7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:40:22 +0200 Subject: [PATCH 3/4] fix(ci): scope readiness permissions to the reconcile job and drop dead constant --- .github/workflows/pr-readiness.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr-readiness.yml b/.github/workflows/pr-readiness.yml index 2631596cc..7263069be 100644 --- a/.github/workflows/pr-readiness.yml +++ b/.github/workflows/pr-readiness.yml @@ -10,12 +10,8 @@ on: - cron: "7,22,37,52 * * * *" # Trusted default-branch code only. The PR head is never checked out or executed. -permissions: - contents: write - checks: read - statuses: read - issues: write - pull-requests: write +# Least privilege: no default permissions; the reconcile job grants only what it needs. +permissions: {} concurrency: group: pr-readiness-${{ github.event.pull_request.number || github.event.check_run.head_sha || github.event.sha || 'sweep' }} @@ -26,6 +22,15 @@ jobs: name: reconcile if: github.event_name != 'check_run' || github.event.check_run.name != 'PR readiness / reconcile' runs-on: ubuntu-latest + # contents: write is required for the draft/ready GraphQL mutations; checks + # and statuses are read for readiness classification; issues/pull-requests + # write maintain the intake labels and one bot status comment. + permissions: + contents: write + checks: read + statuses: read + issues: write + pull-requests: write steps: - name: Checkout trusted readiness script uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -45,12 +50,6 @@ jobs: const { owner, repo } = context.repo; const marker = ""; - const managedLabels = [ - "awaiting-author", - "awaiting-maintainer", - "intake: validating", - "intake: auto-drafted", - ]; async function ensureLabel(name, color, description) { try { From 1acd01d49800f0cebda2108e345835b0e25ef7bf Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:09:01 +0200 Subject: [PATCH 4/4] fix(ci): match real check-run names, exclude admission evidence, dedupe runs, serialize reconciles, honor manual drafts --- .github/scripts/pr-readiness.cjs | 47 ++++++++++-- .github/scripts/pr-readiness.test.cjs | 72 +++++++++++++++++++ .github/workflows/pr-readiness.yml | 14 +++- .../2026-08-02-pr-readiness-gate-design.md | 2 + 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/.github/scripts/pr-readiness.cjs b/.github/scripts/pr-readiness.cjs index dca6d093e..9468b27b2 100644 --- a/.github/scripts/pr-readiness.cjs +++ b/.github/scripts/pr-readiness.cjs @@ -9,15 +9,50 @@ const BLOCKING_CONCLUSIONS = new Set([ "stale", "startup_failure", ]); +// GitHub Actions reports the job name as the check-run name, so the reconcile +// job's own runs surface as "reconcile". Both the display and job-name forms +// are matched so an in-progress reconcile run can never block readiness. const IGNORED_NAMES = new Set([ + "reconcile", "PR readiness / reconcile", "PR readiness", ]); +// Admission state is conveyed by the `intake: admitted` label; admission check +// runs must not count as post-admission evidence, or a PR whose only check is +// a successful admission run would be declared maintainer-ready before +// CodeRabbit or CI ever report. +const ADMISSION_NAMES = new Set([ + "admission", + "PR admission / admission", +]); function normalizeName(value) { return String(value || "").trim(); } +function isManagedCheckName(name) { + return IGNORED_NAMES.has(name) || ADMISSION_NAMES.has(name); +} + +// The Checks API's `latest` filter returns the newest run per check suite, not +// per check name, so repeated invocations on an unchanged head SHA coexist. +// Keep only the newest run per check name before classifying. +function latestByCheckName(checkRuns) { + const latest = new Map(); + for (const check of checkRuns || []) { + const name = normalizeName(check.name); + if (!name) continue; + const existing = latest.get(name); + if ( + !existing || + String(check.started_at || "") >= String(existing.started_at || "") + ) { + latest.set(name, check); + } + } + return [...latest.values()]; +} + function classifyStatuses(statuses) { const pending = []; const failed = []; @@ -25,7 +60,7 @@ function classifyStatuses(statuses) { for (const status of statuses || []) { const name = normalizeName(status.context); - if (!name || IGNORED_NAMES.has(name)) continue; + if (!name || isManagedCheckName(name)) continue; observed += 1; if (status.state === "pending") pending.push(name); else if (status.state !== "success") failed.push(name); @@ -39,9 +74,9 @@ function classifyCheckRuns(checkRuns) { const failed = []; let observed = 0; - for (const check of checkRuns || []) { + for (const check of latestByCheckName(checkRuns)) { const name = normalizeName(check.name); - if (!name || IGNORED_NAMES.has(name)) continue; + if (!name || isManagedCheckName(name)) continue; observed += 1; if (check.status !== "completed") { pending.push(name); @@ -56,8 +91,8 @@ function classifyCheckRuns(checkRuns) { } function admissionCheckPending(checkRuns) { - const admission = (checkRuns || []).find( - (check) => normalizeName(check.name) === "PR admission / admission", + const admission = latestByCheckName(checkRuns).find( + (check) => ADMISSION_NAMES.has(normalizeName(check.name)), ); return Boolean(admission && admission.status !== "completed"); } @@ -93,9 +128,11 @@ function assessReadiness({ admissionPassed, statuses = [], checkRuns = [] }) { module.exports = { ACCEPTABLE_CONCLUSIONS, + ADMISSION_NAMES, BLOCKING_CONCLUSIONS, admissionCheckPending, assessReadiness, classifyCheckRuns, classifyStatuses, + latestByCheckName, }; diff --git a/.github/scripts/pr-readiness.test.cjs b/.github/scripts/pr-readiness.test.cjs index 4431db73c..589392440 100644 --- a/.github/scripts/pr-readiness.test.cjs +++ b/.github/scripts/pr-readiness.test.cjs @@ -6,6 +6,8 @@ const { admissionCheckPending, assessReadiness, classifyCheckRuns, + classifyStatuses, + latestByCheckName, } = require("./pr-readiness.cjs"); describe("assessReadiness", () => { @@ -25,6 +27,15 @@ describe("assessReadiness", () => { assert.deepEqual(result.pending, ["PR admission"]); }); + it("recognizes a pending admission check by its job name", () => { + const result = assessReadiness({ + admissionPassed: false, + checkRuns: [{ name: "admission", status: "in_progress" }], + }); + assert.equal(result.state, "validating"); + assert.equal(admissionCheckPending([{ name: "admission", status: "in_progress" }]), true); + }); + it("returns author action when admission completed with a failure", () => { const result = assessReadiness({ admissionPassed: false, @@ -67,6 +78,14 @@ describe("assessReadiness", () => { assert.deepEqual(result, { state: "maintainer", failed: [], pending: [] }); }); + it("does not treat a successful admission run as post-admission evidence", () => { + const result = assessReadiness({ + admissionPassed: true, + checkRuns: [{ name: "admission", status: "completed", conclusion: "success" }], + }); + assert.equal(result.state, "validating"); + }); + it("does not claim readiness when no checks were observed", () => { assert.equal( assessReadiness({ admissionPassed: true }).state, @@ -84,6 +103,17 @@ describe("assessReadiness", () => { }); assert.equal(result.state, "maintainer"); }); + + it("ignores the reconcile job by its actual check-run name", () => { + const result = assessReadiness({ + admissionPassed: true, + statuses: [{ context: "CodeRabbit", state: "success" }], + checkRuns: [ + { name: "reconcile", status: "in_progress" }, + ], + }); + assert.equal(result.state, "maintainer"); + }); }); describe("classifyCheckRuns", () => { @@ -94,6 +124,48 @@ describe("classifyCheckRuns", () => { ]); assert.deepEqual(result.failed, ["a", "b"]); }); + + it("keeps only the newest run per check name", () => { + const result = classifyCheckRuns([ + { name: "Cross-platform CI", status: "completed", conclusion: "failure", started_at: "2026-08-01T00:00:00Z" }, + { name: "Cross-platform CI", status: "completed", conclusion: "success", started_at: "2026-08-02T00:00:00Z" }, + ]); + assert.equal(result.observed, 1); + assert.deepEqual(result.failed, []); + }); + + it("ignores admission runs in readiness evidence", () => { + const result = classifyCheckRuns([ + { name: "admission", status: "completed", conclusion: "success" }, + ]); + assert.equal(result.observed, 0); + }); +}); + +describe("latestByCheckName", () => { + it("picks the newest run when started_at is present", () => { + const runs = latestByCheckName([ + { name: "a", started_at: "2026-08-01T00:00:00Z" }, + { name: "a", started_at: "2026-08-02T00:00:00Z" }, + { name: "b", started_at: "2026-08-01T00:00:00Z" }, + ]); + assert.deepEqual( + runs.map((r) => r.started_at).sort(), + ["2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z"], + ); + }); +}); + +describe("classifyStatuses", () => { + it("ignores readiness and admission status contexts", () => { + const result = classifyStatuses([ + { context: "CodeRabbit", state: "success" }, + { context: "reconcile", state: "pending" }, + { context: "admission", state: "failure" }, + ]); + assert.equal(result.observed, 1); + assert.deepEqual(result.failed, []); + }); }); describe("admissionCheckPending", () => { diff --git a/.github/workflows/pr-readiness.yml b/.github/workflows/pr-readiness.yml index 7263069be..c46ba6588 100644 --- a/.github/workflows/pr-readiness.yml +++ b/.github/workflows/pr-readiness.yml @@ -14,13 +14,13 @@ on: permissions: {} concurrency: - group: pr-readiness-${{ github.event.pull_request.number || github.event.check_run.head_sha || github.event.sha || 'sweep' }} + group: pr-readiness-${{ github.event.pull_request.head.sha || github.event.check_run.head_sha || github.event.sha || 'sweep' }} cancel-in-progress: true jobs: reconcile: name: reconcile - if: github.event_name != 'check_run' || github.event.check_run.name != 'PR readiness / reconcile' + if: github.event_name != 'check_run' || (github.event.check_run.name != 'reconcile' && github.event.check_run.name != 'PR readiness / reconcile') runs-on: ubuntu-latest # contents: write is required for the draft/ready GraphQL mutations; checks # and statuses are read for readiness classification; issues/pull-requests @@ -169,6 +169,13 @@ jobs: } if (result.state === "maintainer") { + if (pr.draft && !labels.has("intake: auto-drafted")) { + // Author-controlled draft: all gates pass, but the author has + // not asked for review. Do not queue it for maintainers. + await remove("awaiting-author"); + await remove("intake: validating"); + continue; + } await remove("awaiting-author"); await remove("intake: validating"); await add("awaiting-maintainer"); @@ -184,10 +191,11 @@ jobs: await remove("awaiting-maintainer"); if (!pr.draft) { + await add("intake: auto-drafted"); try { await convertToDraft(pr.node_id); - await add("intake: auto-drafted"); } catch (error) { + await remove("intake: auto-drafted"); core.warning(`Could not convert PR #${pull_number} to draft: ${error.message}`); } } diff --git a/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md b/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md index 535b60cae..7856c3e40 100644 --- a/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md +++ b/docs/superpowers/specs/2026-08-02-pr-readiness-gate-design.md @@ -15,6 +15,8 @@ Prevent human review from starting while admission, CI, or CodeRabbit is incompl While the admission check is still running, a PR without `intake: admitted` is classified as `intake: validating` rather than `awaiting-author`, so concurrent admission and readiness runs cannot mislabel a compliant PR or start its inactivity timer. +Admission and reconcile check runs are excluded from post-admission evidence (the check-run names match the job names `admission` and `reconcile`, with the display-name forms kept as fallbacks), repeated runs on the same head SHA are deduplicated per check name, reconciliations for one head SHA are serialized through the concurrency key, and author-controlled drafts are never queued as `awaiting-maintainer`. + ## Safety The workflow uses `pull_request_target` and default-branch scripts only. It never checks out or executes PR-head code. `status` and `check_run` events reconcile quickly; a 15-minute schedule repairs missed events.