diff --git a/.github/scripts/enqueue-guard.test.mjs b/.github/scripts/enqueue-guard.test.mjs index d383e01e..caf495ee 100644 --- a/.github/scripts/enqueue-guard.test.mjs +++ b/.github/scripts/enqueue-guard.test.mjs @@ -254,6 +254,21 @@ test('a Fixes reference with no space before the hash still counts', () => { assert.equal(r.ok, true, r.reasons.join('; ')) }) +// A slice pull request references its parent issue without closing it (#644 / #1148). +for (const ref of ['Related #644', 'Related ThinkFlowLab/FlyBot#1', 'Part of #644', 'Part of ThinkFlowLab/FlyBot#1']) { + test(`a "${ref}" reference is an issue reference`, () => { + const gh = fakeGh({ body: HEADERS + ref + '\n\nthe detail', labels: ['agent-pr'], files: [HOST_FILE], comments: [comment()] }) + const r = main({ gh, env: { REVIEW_POLICY: POLICY, GH_REPO: 'ThinkFlowLab/FlyBot', PR: '566' }, repo: 'ThinkFlowLab/FlyBot', pr: 566 }) + assert.equal(r.ok, true, r.reasons.join('; ')) + }) +} + +test('a body with no Fixes, Closes, Related or Part of reference is still refused', () => { + const r = run({ body: 'Relates to the dock work.\n\nthe detail', comments: [comment()] }) + assert.equal(r.ok, false) + assert.match(r.reasons.join('; '), /no issue reference in the pull request body/) +}) + // The number alone is not a reference: `Fixes 801` names no issue in either repository. test('a Fixes line without the hash is not an issue reference', () => { const r = run({ body: 'Fixes 801\n\nthe detail', comments: [comment()] }) diff --git a/.github/scripts/enqueue-rules.mjs b/.github/scripts/enqueue-rules.mjs index 58bfcb89..07137bb7 100644 --- a/.github/scripts/enqueue-rules.mjs +++ b/.github/scripts/enqueue-rules.mjs @@ -27,7 +27,7 @@ const RECORD_ONLY = /^[\s>*+-]*(?:record only\s*[—–-]+\s*do not (?:merge|que // report, not this regex's job. // The rules only hold where the branch is ours: a fork is exempt, because we cannot push to its head // and the owner keeps their own workflow there (#711/#713). -const ISSUE_REF = /^[\s>#-]*(?:fixes|closes)\s*(?:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?#\d+/im +const ISSUE_REF = /^[\s>#-]*(?:fixes|closes|related|part\s+of)\s*(?:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?#\d+/im // #943: the body says who owns the pull request and who reviews it. `Owner:`/`Reviewer:` were a // sentence in AGENTS.md that nothing read: #940 went from opened to merged in about seven minutes with // the largest diff of the day, and its body carried neither line. @@ -139,7 +139,7 @@ export function guard({ pr, head, labels, body, mechanicalRelease, fork, draft, // their own workflow there (#711/#713). `result.exempt` cannot stand in for either: it is true for T0/T1 // as well, and routine work still names its issue. if (!mechanicalRelease && !fork) { - if (!ISSUE_REF.test(String(body ?? ''))) reasons.push('no issue reference in the pull request body: add "Fixes #" or "Fixes owner/repo#"') + if (!ISSUE_REF.test(String(body ?? ''))) reasons.push('no issue reference in the pull request body: add "Fixes #" or "Fixes owner/repo#" (or "Related #" / "Part of #" to reference it without closing)') if (!labelNames(labels).includes('agent-pr')) reasons.push('missing label: agent-pr') // #943: only a request to enter the queue. A draft is not entering it, and a merged pull request is // never re-judged: this check runs again on every comment (enqueue-guard.yml), so without the second diff --git a/AGENTS.md b/AGENTS.md index b6dac434..25c0c3de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,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 is the later slice of #644. 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 #`, or `Related #` / `Part of #` (a reference only, it does not close)) 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. **`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/CHANGELOG.md b/CHANGELOG.md index da9598a2..258658c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ New entries are written as one fragment file per change in [changelog.d/](change ## [Unreleased] +### Changed + +- The enqueue guard accepts `Related #` / `Part of #` as issue references, which name the issue without closing it ([#1160](https://github.com/ThinkFlowLab/FlyBot/pull/1160)). + ## [0.2.7] - 2026-10-25 ### Added