Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .claude/skills/review-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Owned paths and what each subsystem owns live in [docs/subsystems/](../../../doc
Always include:

```text
Verdict: PASS | FAIL | INC at <full or 12+ hex>
Verdict: PASS | FAIL | INC at <full 40-character sha>
Written-by: Reviewer
Posted-by: <who actually posted it, when the writer could not post it themselves>

Expand All @@ -48,11 +48,19 @@ Not run:
- <check> — reason
```

When the GitHub login posting the conclusion is the pull request author, add this line in that header, before the blank above `Ran:` (#644). A conclusion from a different GitHub login omits it:

```text
Attestation: <full 40-character sha> role=Reviewer
```

The first line is the whole conclusion (#637): nothing may come before it, because the gates read the
first line and treat any other shape as no conclusion. The `Written-by:` line is the attribution every write carries (#627): it names a Bot from the policy's
first line and treat any other shape as no conclusion. The sha is the whole 40 characters (#1112). The `Written-by:` line is the attribution every write carries (#627): it names a Bot from the policy's
`writers`, the gates refuse a report without it, and it is a self-report, not proof — see
[AGENTS.md](../../../AGENTS.md#every-state-changing-write-says-who-wrote-it-627), which is the single source.

`role` is the `Written-by` name and is not an `Owner:` of the pull request. The gate parses the line; it does not check a signature.

What the gates read as evidence (`review-gate.mjs` `parseReport`; #660–#663 refused at the queue for
the first point):

Expand Down Expand Up @@ -127,7 +135,7 @@ Do not fill contributor checkboxes or flip draft status unless asked.
Post the report as a **pull request comment** (or the review body) only when a human asks for it, and
only from the reviewer's own login: `.github/scripts/review-gate.mjs` reads the newest Verifier block
that names the head, from a login in `.github/agent/review-policy.json`. The pull request's own
author counts only when listed in the policy's `selfReview` (the owner's Bot/agent, `Birfy`, may PASS its own PR). Keep the block intact — the `Verdict: … at <sha>` first line, the attribution line, `Ran:`, and `Not run:` when you skipped something — because a
author counts only when listed in the policy's `selfReview` (the owner's Bot/agent, `Birfy`, may PASS its own PR) and, when that login is the author, the header also carries `Attestation: <full sha> role=<Bot>` (#644). Keep the block intact — the `Verdict: … at <sha>` first line, the attribution line, the attestation line when the login is the author, `Ran:`, and `Not run:` when you skipped something — because a
missing `Ran:` command is reported as incomplete evidence (`Not run:` is not checked, owner decision 2026-10-22 09:57 Asia/Shanghai), and a PASS at an older
sha stops counting the moment the tip moves. Posting it is not merge authority: see AGENTS.md step 6.
T0/T1 need no PASS; T2/T3 need a PASS at the tip, and the agent that gated the PR then enqueues it itself (owner decision 2026-10-21).
6 changes: 4 additions & 2 deletions .github/scripts/enqueue-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
//
// So this is the step immediately before that call. It refuses — exit 1, the reason printed — when
// the pull request carries `hold`, when the newest conclusion at its head is not a PASS, or when that
// conclusion is itself marked "record only — do not merge". It also refuses a pull request that names
// conclusion is itself marked "record only — do not merge". A PASS whose GitHub login is the pull
// request author counts only with an `Attestation:` line (#644); `judge` reads that from `decide`.
// It also refuses a pull request that names
// no issue in its body or carries no `agent-pr` label (#718; a mechanical release PR and a fork are
// exempt), so
// "one issue, one pull request" is checked rather than remembered. It reuses `judge({ mode: 'queue' })`, so
Expand Down Expand Up @@ -80,7 +82,7 @@ export function decidePull({ gh = realGh, env = process.env, repo: repoOption, p
// believes it has, and `--sha` lets a caller insist on the head it read a moment ago.
// `judge` asks `adminsFor` when this tier needs a PASS.
const policy = loadPolicy(policyPath)
const result = judge({ labels, files, head, author: pull.user?.login, reports, policy, gh, repo, expectedHead: expectedHead ?? arg('--sha'), mode: 'queue' })
const result = judge({ labels, files, head, author: pull.user?.login, reports, policy, gh, repo, expectedHead: expectedHead ?? arg('--sha'), mode: 'queue', body: pull.body })
const mechanicalRelease = isMechanicalRelease(files.map((f) => f.filename))
// The branch this repository owns, not a contributor's: `head.repo` is null when the fork was deleted,
// which is not ours either. Both sides are lower-cased because the repository name in the API paths is
Expand Down
37 changes: 30 additions & 7 deletions .github/scripts/enqueue-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ const NO_SHARED_LOGIN = policyFile({ reviewers: ['reviewer-agent'] })

// The fixed format (#637): the first line is the conclusion, the evidence below it. `Ran:` is what
// makes a PASS evidence rather than a claim (review-gate's rule, reused here).
const report = ({ tip = HEAD, verdict = 'PASS', extra = [] } = {}) =>
[`Verdict: ${verdict} at ${tip}`, 'Written-by: reviewer-agent', '', 'Ran: npm run verify → pass', 'Not run: smoke', ...extra].join('\n')
const report = ({ tip = HEAD, verdict = 'PASS', extra = [], attest = null } = {}) =>
[
`Verdict: ${verdict} at ${tip}`,
'Written-by: reviewer-agent',
...(attest ? [`Attestation: ${tip} role=${attest}`] : []),
'',
'Ran: npm run verify → pass',
'Not run: smoke',
...extra,
].join('\n')

// #676: only a comment GitHub ties to this repository can be a conclusion, so the fixture carries the
// association the API sends for the shared `Birfy` account.
Expand Down Expand Up @@ -171,12 +179,12 @@ test('#602: hold refuses a T0/T1 pull request too', () => {
assert.equal(r.held, true)
})

test('#602: the self-gate case (author Birfy, PASS by Birfy) is allowed', () => {
test('#602: the self-gate case (author Birfy, PASS by Birfy) is allowed when the comment attests a role that is not the Owner', () => {
const r = main({
gh: fakeGh({
files: [HOST_FILE],
author: 'Birfy',
comments: [{ user: { login: 'Birfy' }, body: report(), created_at: '2026-10-21T10:31:00Z', author_association: 'OWNER' }],
comments: [{ user: { login: 'Birfy' }, body: report({ attest: 'reviewer-agent' }), created_at: '2026-10-21T10:31:00Z', author_association: 'OWNER' }],
}),
env: { REVIEW_POLICY: POLICY, GH_REPO: 'ThinkFlowLab/FlyBot', PR: '566' },
repo: 'ThinkFlowLab/FlyBot',
Expand All @@ -185,6 +193,21 @@ test('#602: the self-gate case (author Birfy, PASS by Birfy) is allowed', () =>
assert.equal(r.ok, true, r.reasons.join('; '))
})

test('#644: the self-gate case with only the author\'s Verdict comment is refused', () => {
const r = main({
gh: fakeGh({
files: [HOST_FILE],
author: 'Birfy',
comments: [{ user: { login: 'Birfy' }, body: report(), created_at: '2026-10-21T10:31:00Z', author_association: 'OWNER' }],
}),
env: { REVIEW_POLICY: POLICY, GH_REPO: 'ThinkFlowLab/FlyBot', PR: '566' },
repo: 'ThinkFlowLab/FlyBot',
pr: 566,
})
assert.equal(r.ok, false)
assert.match(r.reasons.join('; '), /not an attestation/)
})

// #718: a pull request with no issue behind it, or with no `agent-pr` label, is refused before the
// queue: #711/#712/#713 arrived with neither and the guard said nothing, because it only read `hold`,
// the conclusions and the diff. The release PR is the one exception — it has neither by design.
Expand Down Expand Up @@ -274,8 +297,8 @@ test('#718: a fork stays exempt under any --repo spelling', () => {

// The guard is only in the path if the instructions that tell an agent to enqueue name it; otherwise
// the next agent runs `gh pr merge` from memory and #602 is back.
const contributor = (login, at = '2026-10-24T03:42:31Z') =>
({ user: { login }, body: report(), submitted_at: at, author_association: 'CONTRIBUTOR' })
const contributor = (login, at = '2026-10-24T03:42:31Z', body = report()) =>
({ user: { login }, body, submitted_at: at, author_association: 'CONTRIBUTOR' })

test('#925: a MEMBER PASS is accepted and does not ask the permission API', () => {
const gh = fakeGh({ files: [HOST_FILE], comments: [comment()] })
Expand All @@ -302,7 +325,7 @@ test('#925: a selfReview login stamped CONTRIBUTOR is accepted on its own pull r
const gh = fakeGh({
files: [HOST_FILE],
author: 'Birfy',
reviews: [contributor('Birfy')],
reviews: [contributor('Birfy', '2026-10-24T03:42:31Z', report({ attest: 'reviewer-agent' }))],
permission: 'throw',
})
const r = main({ gh, env: { REVIEW_POLICY: POLICY, GH_REPO: 'ThinkFlowLab/FlyBot', PR: '566' }, repo: 'ThinkFlowLab/FlyBot', pr: 566 })
Expand Down
9 changes: 5 additions & 4 deletions .github/scripts/merge-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const short = (s) => String(s ?? '').slice(0, SHORT)
// expectedHead: optional, a head the caller insists on (`--sha` on a hand run); a different live head
// refuses. The queue's base sha is never a head: a merge_group entry passes none here (see
// prFromHeadRef). reports: its comments and review bodies, oldest first (review-gate main()).
export function judge({ labels, files, head, author, reports, policy, expectedHead, mode = 'queue', admins, gh, repo } = {}) {
export function judge({ labels, files, head, author, reports, policy, expectedHead, mode = 'queue', admins, gh, repo, body, owners } = {}) {
const reasons = []
const target = String(head ?? '').toLowerCase()
if (!target) return { ok: false, head: '', verdict: null, reasons: ['no head sha to judge'] }
Expand All @@ -120,7 +120,7 @@ export function judge({ labels, files, head, author, reports, policy, expectedHe
// A tier that needs no PASS also needs no permission lookup.
const verdict = exempt
? { ok: true, verdict: null, by: null, exempt: true }
: decide({ head: target, author, reports, policy, admins: admins ?? adminsFor({ gh, repo, reports, policy, author }) })
: decide({ head: target, author, reports, policy, body, owners, admins: admins ?? adminsFor({ gh, repo, reports, policy, author }) })
if (!verdict.ok) reasons.push(verdict.reason)
// A report says what the queue entry would refuse; only the queue entry refuses. Fail a
// `pull_request` run and it keeps failing after the INC it names is answered by a PASS, because a
Expand Down Expand Up @@ -217,9 +217,10 @@ export function main({ gh = realGh, env = process.env, repo: repoOption, pr: prO
// base copy of risk-gate.mjs (see exemptFromPass).
const files = list(`repos/${repo}/pulls/${pr}/files?per_page=100`)
const policy = loadPolicy(policyPath)
const result = judge({ labels, files, head, author, reports, policy, gh, repo, expectedHead: arg('--sha'), mode })
const result = judge({ labels, files, head, author, reports, policy, gh, repo, expectedHead: arg('--sha'), mode, body: pull.body })
// #960 (B): `enqueue-guard` is not a required context today, so `merge-gate` is the only gate on a
// queue entry — and `judge` reads neither the body nor the labels. The fallback runs the guard's own
// queue entry. `judge` passes the pull request body into `decide` so an author-login PASS is checked
// against `Owner:` (#644). It does not apply the body rules itself: the fallback runs the guard's own
// `guard()` over the same pull request, so a body with no `Owner:`/`Reviewer:`, no `Fixes #<n>`, or no
// `agent-pr` is refused here too, instead of merging under a green check. It answers only for the
// queue: the `pull_request` run is a report and never fails (#639).
Expand Down
24 changes: 19 additions & 5 deletions .github/scripts/merge-gate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,13 @@ test('#654: the workflow hands the script the merge_group base and entry shas',
const REAL = JSON.parse(readFileSync(new URL('./fixtures/review-reports-660-663.json', import.meta.url), 'utf8')).reports
const REAL_POLICY = fileURLToPath(new URL('../agent/review-policy.json', import.meta.url))

const queueReal = (fx, body = fx.comment.body) => main({
const queueReal = (fx, body = fx.comment.body, pullBody) => main({
gh: (...args) => {
const path = args[args.length - 1]
if (path.includes('/files')) return fx.files.map((filename) => JSON.stringify({ filename })).join('\n')
if (path.includes('/comments')) return JSON.stringify({ user: { login: fx.comment.login }, body, created_at: fx.comment.created_at, author_association: 'OWNER' })
if (path.includes('/reviews')) return ''
if (/\/pulls\/\d+$/.test(path)) return JSON.stringify({ head: { sha: fx.head, repo: { full_name: 'ThinkFlowLab/FlyBot' } }, user: { login: fx.author }, labels: [{ name: 'agent-pr' }], body: ['Owner: Reviewer', 'Reviewer: Birfy', 'Fixes #' + fx.pr, ''].join('\n') })
if (/\/pulls\/\d+$/.test(path)) return JSON.stringify({ head: { sha: fx.head, repo: { full_name: 'ThinkFlowLab/FlyBot' } }, user: { login: fx.author }, labels: [{ name: 'agent-pr' }], body: pullBody ?? ['Owner: Reviewer', 'Reviewer: Birfy', 'Fixes #' + fx.pr, ''].join('\n') })
throw new Error('fake gh: unexpected ' + path)
},
env: { GITHUB_STEP_SUMMARY: '', REVIEW_POLICY: REAL_POLICY, GH_REPO: 'ThinkFlowLab/FlyBot', HEAD_REF: `refs/heads/gh-readonly-queue/main/pr-${fx.pr}-c74e1207${'0'.repeat(32)}`, MODE: 'queue' },
Expand All @@ -416,14 +416,28 @@ test('#660: the real PASS, which names what it did not run, passes the queue ent

// Owner decision 2026-10-22 09:57 Asia/Shanghai (#667): "Not run:" is no longer checked, so the three
// reports without one pass as they were written.
test('#660-#663: all four real PASS reports pass the queue entry end to end, as written', () => {
test('#660-#663: a PASS from another GitHub login still passes; the author\'s own comment does not (#644)', () => {
for (const fx of REAL) {
const result = queueReal(fx)
assert.equal(result.ok, true, `#${fx.pr}: ${result.reasons.join('; ')}`)
assert.equal(result.verdict.by, 'Birfy')
assert.equal(result.head, fx.head)
if (fx.author === fx.comment.login) {
assert.equal(result.ok, false, `#${fx.pr} is the author's own comment and carries no attestation`)
assert.match(result.reasons.join('; '), /not an attestation/)
} else {
assert.equal(result.ok, true, `#${fx.pr}: ${result.reasons.join('; ')}`)
assert.equal(result.verdict.by, 'Birfy')
}
}
assert.equal(REAL.filter((fx) => !/^Not run\b/m.test(fx.comment.body)).length, 3, '#661-#663 carry no Not run line')
assert.equal(REAL.filter((fx) => fx.author === fx.comment.login).map((fx) => fx.pr).join(','), '663')
})

test('#644: #663 passes once the author comment attests a role that is not the Owner', () => {
const fx = REAL.find((f) => f.pr === 663)
const body = fx.comment.body.replace('Written-by: Reviewer\n', `Written-by: Reviewer\nAttestation: ${fx.head} role=Reviewer\n`)
const result = queueReal(fx, body, `Owner: Dev\nReviewer: Birfy\nFixes #${fx.pr}\n`)
assert.equal(result.ok, true, result.reasons.join('; '))
assert.equal(result.verdict.by, 'Birfy')
})

test('#660-#663: the real reports still refuse at the queue on a wrong tip, an INC after them, or hold', () => {
Expand Down
Loading
Loading