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
71 changes: 67 additions & 4 deletions .github/scripts/enqueue-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('; '))
})

Expand All @@ -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('; ')}`)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
14 changes: 13 additions & 1 deletion .github/scripts/enqueue-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/merge-gate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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')
})
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<Bot>`. 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 #<n>` / `Closes #<n>`) 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 #<n>` / `Closes #<n>`) 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.

Expand Down
3 changes: 2 additions & 1 deletion docs/autonomous-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading