diff --git a/.github/scripts/enqueue-guard.test.mjs b/.github/scripts/enqueue-guard.test.mjs index 2e85ad4e4..cf5615b91 100644 --- a/.github/scripts/enqueue-guard.test.mjs +++ b/.github/scripts/enqueue-guard.test.mjs @@ -425,8 +425,10 @@ test('#943, reworded by #947: a Reviewer: outside both vocabularies is refused a assert.ok(r.reasons.includes('Reviewer names "someone-else", who is not a writers Bot name'), r.reasons.join('; ')) }) +// `Birfy` used to stand here as the second spelling; since #1228 it is the one value neither line may name, +// so the @ and the case are shown on the listed login itself. test('#943: an @ and any case are fine on a login `reviewers` lists', () => { - const r = run({ body: 'Owner: Dev\nReviewer: @Reviewer-Agent, BIRFY\nFixes #943\n', comments: [comment()] }) + const r = run({ body: 'Owner: Dev\nReviewer: @Reviewer-Agent, REVIEWER-AGENT\nFixes #943\n', comments: [comment()] }) assert.equal(r.ok, true, r.reasons.join('; ')) }) @@ -440,7 +442,9 @@ test('#947: a Reviewer: naming a writers Bot name is accepted', () => { }) test('#947: either vocabulary is accepted, with an @ and in any case', () => { - for (const line of ['Reviewer: @Reviewer2', 'Reviewer: reviewer', 'Reviewer: Birfy', 'Reviewer: Reviewer2, Birfy', 'Reviewer: reviewer-agent, @REVIEWER2']) { + // `Reviewer: Birfy` and `Reviewer: Reviewer2, Birfy` sat in this list until #1228 made the shared identity + // a refusal of its own; the cases that keep them out are the `#1228:` ones below. + for (const line of ['Reviewer: @Reviewer2', 'Reviewer: reviewer', 'Reviewer: reviewer-agent, @REVIEWER2']) { const r = run({ body: `Owner: Dev\n${line}\nFixes #947\n`, comments: [comment()] }) assert.equal(r.ok, true, `${line}: ${r.reasons.join('; ')}`) } @@ -571,8 +575,10 @@ test('#951: overlap in either direction, in a list, or through @ and case is ref assert.equal(ok.ok, true, ok.reasons.join('; ')) }) -// `Birfy` is both a `reviewers` login and the one identity every Bot writes as, so it is the sharpest -// fixture: each line alone is perfectly valid, and only the comparison can refuse it. +// `Birfy` is both a `reviewers` login and the one identity every Bot writes as. Until #1228 that made it the +// sharpest fixture for this comparison — each line alone looked valid and only the comparison could refuse +// it; the value is now refused on its own too (:the `#1228:` cases below), so this one pins that the overlap +// refusal is still there beside it, unchanged. test('#951: a name that is both a login and a writers name is refused on both lines', () => { const r = run({ body: 'Owner: Birfy\nReviewer: Birfy\nFixes #951\n', comments: [comment()] }) assert.equal(r.ok, false) @@ -664,6 +670,63 @@ test('#962: Docs is registered in writers; Owner: Docs passes and an unknown own }) +// #1228: the two lines name a workstation, but `reviewers` (logins) and `writers` (Bot names) overlap on +// exactly one value — `Birfy`, the shared identity every Bot writes as. Each line alone looked valid, so a +// body could name no Bot at all and still enter the queue; #1214 merged with `Reviewer: Birfy`. The value is +// refused on either line, with the same normalisation (`@`, case) the two vocabularies already use. +const SHARED_OWNER = 'Owner names "birfy", who is the shared write identity every Bot writes as — name the Bot that claims this pull request' +const SHARED_REVIEWER = 'Reviewer names "birfy", who is the shared write identity every Bot writes as — name the Bot that reviews' +const sharedRefusals = (r) => r.reasons.filter((reason) => /is the shared write identity/.test(reason)) + +test('#1228: Reviewer: Birfy names no reviewer, and the refusal names the line and the value', () => { + const r = run({ body: 'Owner: Dev3\nReviewer: Birfy\nFixes #1228\n', comments: [comment()] }) + assert.equal(r.ok, false) + assert.deepEqual(sharedRefusals(r), [SHARED_REVIEWER]) +}) + +test('#1228: Owner: Birfy claims no owner, and the refusal names the line and the value', () => { + const r = run({ body: 'Owner: Birfy\nReviewer: Reviewer\nFixes #1228\n', comments: [comment()] }) + assert.equal(r.ok, false) + assert.deepEqual(sharedRefusals(r), [SHARED_OWNER]) +}) + +test('#1228: two real Bots on the two lines are still accepted', () => { + for (const body of ['Owner: Dev3\nReviewer: Reviewer\nFixes #1228\n', 'Owner: Dev3\nReviewer: reviewer-agent\nFixes #1228\n']) { + const r = run({ body, comments: [comment()] }) + assert.equal(r.ok, true, `${body}: ${r.reasons.join('; ')}`) + assert.deepEqual(sharedRefusals(r), [], `${body}: the rule is about one value, not about the lines`) + } +}) + +test('#1228: @ and case are normalised first, so the shared identity is refused once, not twice', () => { + const r = run({ body: 'Owner: Dev3\nReviewer: @BIRFY\nFixes #1228\n', comments: [comment()] }) + assert.equal(r.ok, false) + assert.deepEqual(sharedRefusals(r), [SHARED_REVIEWER]) +}) + +test('#1228: one unusable name in a list is enough', () => { + const r = run({ body: 'Owner: Dev3\nReviewer: Birfy, Reviewer\nFixes #1228\n', comments: [comment()] }) + assert.equal(r.ok, false) + assert.deepEqual(sharedRefusals(r), [SHARED_REVIEWER]) +}) + +test('#1228: the shared identity on both lines is refused on both, and the overlap refusal is unchanged', () => { + const r = run({ body: 'Owner: Birfy\nReviewer: Birfy\nFixes #1228\n', comments: [comment()] }) + assert.equal(r.ok, false) + assert.deepEqual(sharedRefusals(r), [SHARED_OWNER, SHARED_REVIEWER]) + assert.deepEqual(selfReviewRefusals(r), ['Reviewer names "birfy", who is also the Owner of this pull request']) +}) + +// The checkpoint compares `claim` with the Owner names, so it cannot become the way in: an Owner line that +// names nobody is already refused, and nothing new is added to the block. +test('#1228: the checkpoint cannot become the way in — claim "Birfy" under Owner: Birfy is refused', () => { + const body = `Owner: Birfy\nReviewer: Reviewer\nFixes #1228\n\n${checkpoint({ claim: 'Birfy', issue: 1228 })}` + const r = run({ raw: true, body, comments: [comment()] }) + assert.equal(r.ok, false) + assert.deepEqual(sharedRefusals(r), [SHARED_OWNER]) + assert.equal(r.reasons.some((reason) => /checkpoint field "claim"/.test(reason)), false, 'no new field rule: the Owner line is what refuses it') +}) + // ---- #1112: the guard reported a PASS that had not been posted yet, and a re-push could go green again. // The three cases below are one per failure mode the issue names; each is red on the code before it. diff --git a/.github/scripts/enqueue-rules.mjs b/.github/scripts/enqueue-rules.mjs index 003e25912..237c2a360 100644 --- a/.github/scripts/enqueue-rules.mjs +++ b/.github/scripts/enqueue-rules.mjs @@ -56,6 +56,12 @@ const labelNames = (labels) => (labels ?? []).map((l) => String(typeof l === 'st // A Bot name can only through the one identity every Bot writes as, `Birfy`, so it names a reviewer only // while that account is in `reviewers`; with it gone the name is vocabulary and the line is refused. const SHARED_WRITE_LOGIN = 'Birfy' +// #1228: that same account is the one value both vocabularies contain, so both lines accepted it: a body +// could name `Birfy` on `Owner:` (a `writers` name) or on `Reviewer:` (a `reviewers` login) and the queue +// entry read it as a workstation. #1214 merged with `Reviewer: Birfy` for exactly that reason. Neither line +// is about the account; each asks which Bot claims, and which Bot reviews, so the shared identity is refused +// before either vocabulary is consulted — otherwise "listed" would keep standing in for "named a Bot". +const SHARED_WRITE_NAME = SHARED_WRITE_LOGIN.toLowerCase() // One line's names: split on commas, strip a leading `@`, compare case-insensitively. Both header lines // read their values this way so `@Dev`, `dev` and `DEV` are one name wherever they appear (#951). const headerNames = (line, pattern) => line.replace(pattern, '').split(',').map((value) => value.trim().replace(/^@/, '').toLowerCase()).filter(Boolean) @@ -73,7 +79,8 @@ function headerReasons(body, policy) { const ownerNames = owner ? [...new Set(headerNames(owner, OWNER_LINE))] : [] if (!ownerNames.length) reasons.push('missing line: Owner:') for (const name of ownerNames) { - if (!bots.has(name)) reasons.push(`Owner names "${name}", who is not a writers Bot name`) + if (name === SHARED_WRITE_NAME) reasons.push(`Owner names "${name}", who is the shared write identity every Bot writes as — name the Bot that claims this pull request`) + else if (!bots.has(name)) reasons.push(`Owner names "${name}", who is not a writers Bot name`) } const owners = new Set(ownerNames) const reviewer = lines.find((line) => REVIEWER_LINE.test(line)) @@ -84,6 +91,11 @@ function headerReasons(body, policy) { if (!names.length) reasons.push('missing line: Reviewer:') let passable = false for (const name of names) { + // #1228: refused before the vocabularies, because this value is the one both of them list. + if (name === SHARED_WRITE_NAME) { + reasons.push(`Reviewer names "${name}", who is the shared write identity every Bot writes as — name the Bot that reviews`) + continue + } if (logins.has(name)) { passable = true continue diff --git a/.github/scripts/merge-gate.test.mjs b/.github/scripts/merge-gate.test.mjs index c7ddcacfd..a4e46fe86 100644 --- a/.github/scripts/merge-gate.test.mjs +++ b/.github/scripts/merge-gate.test.mjs @@ -409,7 +409,7 @@ const queueReal = (fx, body = fx.comment.body, pullBody) => main({ 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: pullBody ?? bodyWithCheckpoint(['Owner: Reviewer', 'Reviewer: Birfy', 'Fixes #' + fx.pr, ''].join('\n'), fx.files, fx.pr) }) + 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 ?? bodyWithCheckpoint(['Owner: Reviewer', 'Reviewer: Reviewer2', 'Fixes #' + fx.pr, ''].join('\n'), fx.files, fx.pr) }) 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' }, @@ -454,7 +454,7 @@ test('#660-#663: a PASS from another GitHub login still passes; the author\'s ow 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, bodyWithCheckpoint(`Owner: Dev\nReviewer: Birfy\nFixes #${fx.pr}\n`, fx.files, fx.pr)) + const result = queueReal(fx, body, bodyWithCheckpoint(`Owner: Dev\nReviewer: Reviewer2\nFixes #${fx.pr}\n`, fx.files, fx.pr)) assert.equal(result.ok, true, result.reasons.join('; ')) assert.equal(result.verdict.by, 'Birfy') }) diff --git a/AGENTS.md b/AGENTS.md index ba44d05f0..0f7ce11a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,7 +175,7 @@ What this buys is presence and a name from a closed list, not truth: with one sh - **An author-login comment is not a review (#644, option B, first slice).** When the conclusion's GitHub login is the pull request author, `selfReview` is not enough. The header — the lines before the first blank, and within the first eight non-empty lines — carries `Attestation: <40-character sha> role=`. The sha is the whole tip (#1112). `role` is the `Written-by` name. That role is not an `Owner:` of the pull request. A free-form comment, a `Verdict:` with no attestation line, a line after the blank, a short sha, a role that is not the `Written-by` name, or a role that is the Owner refuses. A conclusion from a different GitHub login is unchanged. `decide` in [`review-gate.mjs`](.github/scripts/review-gate.mjs) is the check, and the reviewed-tip job, `enqueue-guard` and `merge-gate` all call it. The line is parsed, not signed. A signature was carved out later (#644) and is shipped but not enabled (#1110, #1111): the `signers` roster is empty, so no conclusion is signed today. T0/T1 still need no PASS. - **`hold` is human-only.** Only a person sets the label and only a person lifts it; automation refuses a held pull request and never removes the label itself. That is the one human input left on the merge path. - **The label awaiting-approval is retired.** A PASS at the tip goes straight to the queue through the enqueue guard (#687); no pull request waits for an approval click and the label is not set again. -- **A pull request enters the queue with its issue and its `agent-pr` label.** On a branch this repository owns, the enqueue guard also refuses one whose body names no issue (`Fixes #` / `Closes #`) and one without the `agent-pr` label (#718), so "one issue, one pull request" is checked rather than remembered. It refuses a **live, non-draft** request whose body carries no `Owner:` line or no `Reviewer:` line in its first 20 lines, or a `Reviewer:` that is neither a `reviewers` login nor a `writers` Bot name, or that names nobody who can record a PASS — a Bot name counts only through the one identity every Bot writes as, `Birfy`, so it names nobody while that account is outside `reviewers` (#947); a `Reviewer:` that names the same Bot as the `Owner:` is refused too, compared name by name after commas, a leading `@` and case are normalised, because one Bot owning and reviewing the same pull request is the shape #940 had with no lines at all (#951); both lines also need a value the policy knows — a `Reviewer:` outside both vocabularies and an `Owner:` outside `writers` are refused, and a line with no value is the missing line, so an empty `Owner:` cannot clear the comparison (#951); the refusal names the missing line, or the word and the list it missed (#943, #947, #951; #940 merged in about seven minutes with the largest diff of the day and neither line). A **merged** pull request is never re-judged: this check also runs on every comment, and history is not a request. Two exemptions: a mechanical release PR, which carries neither by design, and a **fork** (`pull.head.repo.full_name` is not this repository), whose head we cannot push to and where the contributor keeps their own workflow. +- **A pull request enters the queue with its issue and its `agent-pr` label.** On a branch this repository owns, the enqueue guard also refuses one whose body names no issue (`Fixes #` / `Closes #`) and one without the `agent-pr` label (#718), so "one issue, one pull request" is checked rather than remembered. It refuses a **live, non-draft** request whose body carries no `Owner:` line or no `Reviewer:` line in its first 20 lines, or a `Reviewer:` that is neither a `reviewers` login nor a `writers` Bot name, or that names nobody who can record a PASS — a Bot name counts only through the one identity every Bot writes as, `Birfy`, so it names nobody while that account is outside `reviewers` (#947); a `Reviewer:` that names the same Bot as the `Owner:` is refused too, compared name by name after commas, a leading `@` and case are normalised, because one Bot owning and reviewing the same pull request is the shape #940 had with no lines at all (#951); both lines also need a value the policy knows — a `Reviewer:` outside both vocabularies and an `Owner:` outside `writers` are refused, and a line with no value is the missing line, so an empty `Owner:` cannot clear the comparison (#951); neither line may name `Birfy`, the one identity every Bot writes as, because it is the single value both vocabularies list and so names no workstation at all (#1228); the refusal names the missing line, or the word and the list it missed (#943, #947, #951; #940 merged in about seven minutes with the largest diff of the day and neither line). A **merged** pull request is never re-judged: this check also runs on every comment, and history is not a request. Two exemptions: a mechanical release PR, which carries neither by design, and a **fork** (`pull.head.repo.full_name` is not this repository), whose head we cannot push to and where the contributor keeps their own workflow. **`enqueue-guard` writes a check on the head, and the queue's ruleset does not require it** (#812, re-read 2026-10-24). Team pull requests merge upstream in `ThinkFlowLab/FlyBot` through the `main merge queue`, whose ruleset requires three contexts — `Node 24 · DSH 0.2.0-rc.2`, `tests-ok` and `merge-gate`; the paragraph below (#960) is how `merge-gate` reads the body the queue does not read. `enqueue-guard` still runs on that head, and on the queue entry: [`enqueue-guard.yml`](.github/workflows/enqueue-guard.yml) takes its code from `main` and never from the pull request, on every event that can change the answer for a fixed head — a push, a label added or removed, a comment, a review body, or a manual run — and calls [`enqueue-guard.mjs`](.github/scripts/enqueue-guard.mjs)'s own decision, the same function that prints the command, so the check and the command cannot disagree. It refuses a head that carries a `hold`, a pull request with no `agent-pr`, or a body that names no issue, and — **for T2 and T3 only** — a head that has no PASS naming that exact tip or a PASS that lists no `Ran:`; a T0/T1 change and the mechanical release pull request need no PASS at all, because the tier is computed from the diff and never read from a `risk:T*` label (#640). A new commit is a new head and a new check run, and a green check never carries over. diff --git a/docs/autonomous-dev.md b/docs/autonomous-dev.md index a6d803d61..fda1434e6 100644 --- a/docs/autonomous-dev.md +++ b/docs/autonomous-dev.md @@ -294,7 +294,8 @@ PASS is never a queue permit on its own (#602); on a live, non-draft pull reques Bot name, or a Bot name whose shared `Birfy` identity is not in `reviewers`, #947, or a `Reviewer:` naming the same Bot as the `Owner:`, compared per name after commas, `@` and case, #951; both lines need a value the policy knows — an `Owner:` outside `writers` is refused and a line with no value is the missing line, so an empty `Owner:` cannot clear the -comparison, #951), naming the missing line or the word +comparison, #951; and neither line may name the shared `Birfy` identity, the one value both vocabularies list and so the +one value that names no workstation, #1228), naming the missing line or the word that missed both lists (#943) — a merged pull request is not re-judged, because this check also runs on every comment; the same call refuses a body whose `## Checkpoint` block is missing or wrong, one field at a time ([AGENTS.md → The checkpoint block](../AGENTS.md#the-checkpoint-block-a-hand-off-the-next-bot-can-read-1202), skeleton in