From 0c47709ba38272d07f2d9f21a93d8cf9ca272bd8 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Fri, 4 Sep 2026 22:20:58 +0100 Subject: [PATCH] ci: require a closing magic word for the Linear link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `V2-123` in a PR description does not link the PR to the issue — Linear ignores it — so a PR could pass the `linear-link` check while never appearing on the issue and never driving it to Merged when it lands on `main`. ant-node #216 is the concrete case: it carried `V2-1033` under the Linear heading, had no identifier in its branch name, and had to be attached to the issue by hand. The `linear-link` check now requires one of Linear's closing magic words followed by the issue key in the PR body. The accepted set is taken verbatim from https://linear.app/docs/github — close / fix / resolve / complete / implement in every tense (`-s`, `-d`, `-ing`) plus the phrase `linear issue`, case-insensitive, with either the key or a linear.app issue URL. Linear's linking-only families (`ref`, `part of`, `towards`, `relates to`) attach a PR without driving the Merged transition, so they are deliberately not accepted. An identifier in the branch name or the PR title remains an accepted alternative, since both also link the PR. The magic word and the key must sit on the same line, so a paragraph that merely ends in "...closes." cannot pair up with a bare key further down the body, and the template's own `## Linear issue` heading cannot pair up with a bare key on the line beneath it. `pr-template` applies the same rule to the `## Linear issue` section, and the template comment and CLAUDE.md now ask for the closing form and say why the bare and linking-only forms do not work. The self-test matrix grows from 17 to 52 cases: one per documented closing word, the URL form, case-insensitivity, the linking-only rejections, word-boundary cases, and the bare-reference rejections. Closes V2-1161 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WN8hjL7E8qcCxJBgT9vFt6 --- .github/PULL_REQUEST_TEMPLATE.md | 17 +++++- .github/scripts/check_pr.py | 90 +++++++++++++++++++++++++++----- .github/scripts/test_check_pr.py | 47 +++++++++++++++-- CLAUDE.md | 11 +++- 4 files changed, 143 insertions(+), 22 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ac948bfd..9374013b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,19 @@ ## Linear issue - + ## Risk tier + ## Risk tier - [ ] T0 ## Semver impact - [ ] fix """ +# Linear's closing magic words, from https://linear.app/docs/github. Restated here +# rather than imported from check_pr.py so the matrix is an independent assertion +# about what the checker must accept, not a tautology. +CLOSING_MAGIC_WORDS = ( + "close", "closes", "closed", "closing", + "fix", "fixes", "fixed", "fixing", + "resolve", "resolves", "resolved", "resolving", + "complete", "completes", "completed", "completing", + "implement", "implements", "implemented", "implementing", +) + # (name, mode, env, expected_exit) CASES = [ # --- linear-link: rejections --- @@ -67,10 +78,35 @@ def body_without(section_swap): ("linear: linear.app/changelog", "linear", {"PR_BODY": "see https://linear.app/changelog", "PR_BRANCH": "x"}, 1), ("linear: linear.app/not-an-issue", "linear", {"PR_BODY": "https://linear.app/not-an-issue", "PR_BRANCH": "x"}, 1), ("linear: unfilled template (example in comment)", "linear", {"PR_BODY": UNFILLED, "PR_BRANCH": "x"}, 1), - # --- linear-link: acceptances --- - ("linear: issue URL in body", "linear", {"PR_BODY": "https://linear.app/autonominetwork/issue/V2-719/foo"}, 0), + # A bare reference in the body does not link the PR in Linear (V2-1161). + ("linear: bare key in body only", "linear", {"PR_BODY": "V2-1161", "PR_BRANCH": "x"}, 1), + ("linear: bare issue URL in body only", "linear", {"PR_BODY": "https://linear.app/autonominetwork/issue/V2-719/foo", "PR_BRANCH": "x"}, 1), + ("linear: magic word without a key", "linear", {"PR_BODY": "Closes the gap", "PR_BRANCH": "x"}, 1), + ("linear: magic word on its own line from the key", "linear", {"PR_BODY": "Closes\n\nV2-1161", "PR_BRANCH": "x"}, 1), + # Linear's linking-only families attach the PR but do not drive the Merged + # transition, so they are not accepted as the closing form. + ("linear: 'part of' is linking-only", "linear", {"PR_BODY": "part of V2-1161", "PR_BRANCH": "x"}, 1), + ("linear: 'ref' is linking-only", "linear", {"PR_BODY": "ref V2-1161", "PR_BRANCH": "x"}, 1), + ("linear: 'towards' is linking-only", "linear", {"PR_BODY": "towards V2-1161", "PR_BRANCH": "x"}, 1), + ("linear: 'relates to' is linking-only", "linear", {"PR_BODY": "relates to V2-1161", "PR_BRANCH": "x"}, 1), + ("linear: magic word as a word prefix", "linear", {"PR_BODY": "prefix V2-1161", "PR_BRANCH": "x"}, 1), + ("linear: magic word as a word suffix", "linear", {"PR_BODY": "fixture V2-1161", "PR_BRANCH": "x"}, 1), + # --- linear-link: acceptances (Linear's full closing set, any tense) --- + ("linear: Closes + key in body", "linear", {"PR_BODY": "Closes V2-1161", "PR_BRANCH": "x"}, 0), + ("linear: lower-cased magic word", "linear", {"PR_BODY": "closes v2-1161", "PR_BRANCH": "x"}, 0), + ("linear: Closes + issue URL in body", "linear", {"PR_BODY": "Closes https://linear.app/autonominetwork/issue/V2-719/foo", "PR_BRANCH": "x"}, 0), + ("linear: closing form inside prose", "linear", {"PR_BODY": "This one closes V2-1161 at last.", "PR_BRANCH": "x"}, 0), + ("linear: 'linear issue' phrase", "linear", {"PR_BODY": "Linear issue V2-1161", "PR_BRANCH": "x"}, 0), ("linear: key in branch (lowercased)", "linear", {"PR_BRANCH": "chrisoneil/v2-720-ci-check"}, 0), ("linear: key in title", "linear", {"PR_TITLE": "AUTO-42 do the thing", "PR_BRANCH": "x"}, 0), +] + [ + # One case per closing magic word Linear documents, capitalised as an author + # would write it — e.g. "Fixed V2-1161" links and closes in Linear, so it must + # pass here too. + (f"linear: '{word}'", "linear", + {"PR_BODY": f"{word.capitalize()} V2-1161", "PR_BRANCH": "x"}, 0) + for word in CLOSING_MAGIC_WORDS +] + [ # --- pr-template: acceptances --- ("template: valid T0 body", "template", {"PR_BASE": "main", "PR_BODY": VALID_BODY}, 0), ("template: rc-* base is a no-op pass", "template", {"PR_BASE": "rc-2025.10", "PR_BODY": "anything"}, 0), @@ -86,6 +122,9 @@ def body_without(section_swap): ("- [x] T0 — docs / tooling / CI.", "- [x] T2 — behavioural."))}, 1), ("template: no tier checked", "template", {"PR_BASE": "main", "PR_BODY": body_without( ("- [x] T0 — docs / tooling / CI.", "- [ ] T0 — docs / tooling / CI."))}, 1), + ("template: bare key under '## Linear issue'", "template", {"PR_BASE": "main", "PR_BODY": body_without( + ("- Closes https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template", + "- V2-719"))}, 1), ("template: two tiers checked", "template", {"PR_BASE": "main", "PR_BODY": body_without( ("- [x] T0 — docs / tooling / CI.", "- [x] T0 a\n- [x] T2 b"))}, 1), ] diff --git a/CLAUDE.md b/CLAUDE.md index 5dcf95a6..a126dcf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,8 +115,15 @@ description. - **Fill every field.** Leave nothing blank; if a value isn't determinable, ask before opening the PR. -- **Link the Linear issue** — an issue key like `V2-123` or a `linear.app` URL. - CI blocks PRs with no linked Linear issue. +- **Link the Linear issue with a closing magic word** — write `Closes V2-123` in + the `## Linear issue` section, one line per issue. Any of Linear's closing + words works, in any tense (`close` / `fix` / `resolve` / `complete` / + `implement`, plus their `-s`, `-d` and `-ing` forms), and the key may be a + `linear.app` issue URL. A bare `V2-123` does **not** link the PR at all, and + the linking-only words (`ref`, `part of`, `towards`, `relates to`) attach it + without driving the Merged transition — CI rejects both. An issue key in the + branch name or PR title also links, but write the closing form anyway; it is + what moves the issue to Merged when the PR lands on `main`. - **Check exactly one Risk tier box and exactly one Semver impact box.** Propose them from the change; a human confirms them at review. - An **ADR link is required for Tier 2/3**.