Skip to content

Commit f3d6e26

Browse files
jacderidaclaude
andcommitted
ci: require a closing magic word for the Linear link
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WN8hjL7E8qcCxJBgT9vFt6
1 parent 051469c commit f3d6e26

4 files changed

Lines changed: 143 additions & 22 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
## Linear issue
2-
<!-- REQUIRED. Link the issue: an issue key like V2-123, or a linear.app URL.
3-
CI blocks PRs with no linked Linear issue. -->
2+
<!-- REQUIRED. Use a closing magic word + the issue key, one line per issue:
3+
4+
Closes V2-123
5+
6+
Any of Linear's closing words works, in any tense — close / fix / resolve /
7+
complete / implement, plus their -s, -d and -ing forms, and the phrase
8+
`linear issue`. The key may be a linear.app/<workspace>/issue/<key> URL.
9+
10+
The closing form is what makes Linear attach the PR to the issue and move
11+
the issue to Merged when this lands on main. A bare `V2-123` does NOT link
12+
the PR — Linear ignores it — so CI rejects it. Linear's linking-only words
13+
(`ref`, `part of`, `towards`, `relates to`) do attach the PR but do not
14+
drive the Merged transition, so CI does not accept those either. (An issue
15+
key in the branch name or the PR title also links the PR, but write the
16+
closing form here anyway.) -->
417

518
## Risk tier
619
<!-- Check exactly one. Boundary question: does this change node behavior, the wire

.github/scripts/check_pr.py

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
no-op that passes on rc-* so hotfix/regression PRs are
1111
not forced to carry the full template).
1212
13+
Linear only attaches a PR to an issue in three ways: the issue key in the branch
14+
name, the key in the PR title, or a closing magic word followed by the key in the
15+
PR description ("Closes V2-123"). A *bare* key in the description does not link
16+
the PR — Linear ignores it — so the linear check no longer accepts one (V2-1161).
17+
1318
PR fields are read from the environment (set by the workflow from the event
1419
payload): PR_TITLE, PR_BODY, PR_BRANCH, PR_BASE.
1520
@@ -28,14 +33,45 @@
2833

2934
# Case-insensitive so it also matches Linear-generated branch names, which are
3035
# lower-cased (e.g. chrisoneil/v2-720-...).
31-
LINEAR_KEY = re.compile(
32-
r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b", re.IGNORECASE
33-
)
36+
LINEAR_KEY_PATTERN = r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b"
37+
LINEAR_KEY = re.compile(LINEAR_KEY_PATTERN, re.IGNORECASE)
3438
# A real Linear issue URL: linear.app/<workspace>/issue/<KEY>[/<slug>]. Constrained
3539
# to the /issue/<key> path so generic pages (linear.app/changelog,
3640
# linear.app/not-an-issue) do not count as a linked issue.
37-
LINEAR_URL = re.compile(
38-
r"linear\.app/[^/\s]+/issue/[A-Za-z][A-Za-z0-9]*-[0-9]+", re.IGNORECASE
41+
LINEAR_URL_PATTERN = r"linear\.app/[^/\s]+/issue/[A-Za-z][A-Za-z0-9]*-[0-9]+"
42+
LINEAR_URL = re.compile(LINEAR_URL_PATTERN, re.IGNORECASE)
43+
44+
# Linear's *closing* magic words, verbatim from https://linear.app/docs/github.
45+
# Only these both attach the PR and drive the issue to Merged when it lands on
46+
# main. Linear's other families — "ref / refs / references", "part of /
47+
# contributes to / toward / towards", "relates to / related to" — attach the PR
48+
# without the status transition, so they are deliberately not accepted here.
49+
MAGIC_WORDS = (
50+
"close", "closes", "closed", "closing",
51+
"fix", "fixes", "fixed", "fixing",
52+
"resolve", "resolves", "resolved", "resolving",
53+
"complete", "completes", "completed", "completing",
54+
"implement", "implements", "implemented", "implementing",
55+
"linear issue",
56+
)
57+
# The five stems, for failure messages — spelling out all 21 forms is unreadable.
58+
MAGIC_WORD_STEMS = ("close", "fix", "resolve", "complete", "implement")
59+
# "<magic word> V2-123" or "<magic word> https://linear.app/<ws>/issue/V2-123/...".
60+
# This is the form the PR template asks for; a bare key does not match. The
61+
# separator is [ \t]+ rather than \s+ so the two halves must sit on the same
62+
# line — a paragraph that merely ends in "...closes." cannot pair up with a bare
63+
# key further down the body, and the template's own "## Linear issue" heading
64+
# cannot pair up with a bare key on the line beneath it. Longest alternative
65+
# first so "closes" is not shadowed by "close".
66+
LINEAR_CLOSES = re.compile(
67+
r"\b(?:"
68+
+ "|".join(re.escape(w) for w in sorted(MAGIC_WORDS, key=len, reverse=True))
69+
+ r")[ \t]+(?:<)?(?:https?://)?(?:"
70+
+ LINEAR_URL_PATTERN
71+
+ r"|"
72+
+ LINEAR_KEY_PATTERN
73+
+ r")",
74+
re.IGNORECASE,
3975
)
4076

4177
# Canonical section headings, exactly as they appear in the template, keyed by
@@ -97,15 +133,35 @@ def sections(body):
97133

98134
def check_linear():
99135
# Strip comments from the body so the template's own example does not count.
100-
ref = linear_ref(env("PR_TITLE"), strip_comments(env("PR_BODY")), env("PR_BRANCH"))
136+
closes = LINEAR_CLOSES.search(strip_comments(env("PR_BODY")))
137+
if closes:
138+
ok(f"✅ Linear link found in the PR body: {closes.group(0)}")
139+
# A key in the branch name or the PR title also links the PR in Linear, so
140+
# either is an accepted alternative to the closing form.
141+
ref = linear_ref(env("PR_TITLE"), env("PR_BRANCH"))
101142
if ref:
102-
ok(f"✅ Linear reference found: {ref}")
143+
ok(f"✅ Linear link found in the PR title / branch name: {ref}")
103144
fail(
104145
"❌ No linked Linear issue found.\n\n"
105-
"Every PR must reference a Linear issue — an issue key ("
106-
+ " / ".join(f"{p}-123" for p in LINEAR_TEAM_PREFIXES)
107-
+ "), or a linear.app/<workspace>/issue/<key> URL — in the PR title, body,\n"
108-
"or branch name. Add it to the '## Linear issue' section and update the PR."
146+
"Linear attaches a PR to an issue in exactly three ways. Use one:\n\n"
147+
" 1. A closing magic word + the issue key in the PR body — preferred:\n\n"
148+
" Closes V2-123\n\n"
149+
" One line per issue if this PR closes several. Any of Linear's closing\n"
150+
" words works, in any tense — "
151+
+ " / ".join(MAGIC_WORD_STEMS)
152+
+ ", plus their -s, -d and -ing\n"
153+
" forms, and the phrase 'linear issue'. The key may be a\n"
154+
" linear.app/<workspace>/issue/<key> URL instead.\n"
155+
" 2. The issue key in the branch name, e.g. chrisoneil/v2-123-short-slug.\n"
156+
" 3. The issue key in the PR title.\n\n"
157+
"A bare '"
158+
+ LINEAR_TEAM_PREFIXES[0]
159+
+ "-123' in the body does NOT link the PR — Linear ignores it, the PR never\n"
160+
"appears on the issue, and the issue never moves to Merged when this lands.\n"
161+
"Linear's linking-only words ('ref', 'part of', 'towards', 'relates to') do\n"
162+
"attach the PR, but they do not drive the Merged transition, so this check\n"
163+
"does not accept them either.\n"
164+
"Put the closing form under the '## Linear issue' heading and update the PR."
109165
)
110166

111167

@@ -168,9 +224,15 @@ def check_template():
168224
f"'## Compatibility': fill in {axis} (use 'none' if not applicable)"
169225
)
170226

171-
# Linear reference present in its own section (comments stripped).
172-
if "linear issue" in secs and not linear_ref(strip_comments(secs["linear issue"])):
173-
errors.append("'## Linear issue': add the issue key or a linear.app link")
227+
# Linear reference present in its own section, in the closing form that
228+
# actually links the PR (comments stripped so the template's example is inert).
229+
if "linear issue" in secs and not LINEAR_CLOSES.search(
230+
strip_comments(secs["linear issue"])
231+
):
232+
errors.append(
233+
"'## Linear issue': use the closing form, e.g. 'Closes V2-123' — a bare "
234+
"key does not link the PR in Linear"
235+
)
174236

175237
# ADR must be filled explicitly: 'n/a' for T0/T1, a link for T2/T3.
176238
adr = strip_comments(secs.get("adr", "")).strip()

.github/scripts/test_check_pr.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
VALID_BODY = """\
1717
## Linear issue
18-
- https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template
18+
- Closes https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template
1919
2020
## Risk tier
2121
- [x] T0 — docs / tooling / CI.
@@ -52,13 +52,24 @@ def body_without(section_swap):
5252
# Unfilled template: the only Linear-looking token is the example in a comment.
5353
UNFILLED = """\
5454
## Linear issue
55-
<!-- REQUIRED. Link the issue: an issue key like V2-123, or a linear.app URL. -->
55+
<!-- REQUIRED. Use the closing form, one line per issue: Closes V2-123 -->
5656
## Risk tier
5757
- [ ] T0
5858
## Semver impact
5959
- [ ] fix
6060
"""
6161

62+
# Linear's closing magic words, from https://linear.app/docs/github. Restated here
63+
# rather than imported from check_pr.py so the matrix is an independent assertion
64+
# about what the checker must accept, not a tautology.
65+
CLOSING_MAGIC_WORDS = (
66+
"close", "closes", "closed", "closing",
67+
"fix", "fixes", "fixed", "fixing",
68+
"resolve", "resolves", "resolved", "resolving",
69+
"complete", "completes", "completed", "completing",
70+
"implement", "implements", "implemented", "implementing",
71+
)
72+
6273
# (name, mode, env, expected_exit)
6374
CASES = [
6475
# --- linear-link: rejections ---
@@ -67,10 +78,35 @@ def body_without(section_swap):
6778
("linear: linear.app/changelog", "linear", {"PR_BODY": "see https://linear.app/changelog", "PR_BRANCH": "x"}, 1),
6879
("linear: linear.app/not-an-issue", "linear", {"PR_BODY": "https://linear.app/not-an-issue", "PR_BRANCH": "x"}, 1),
6980
("linear: unfilled template (example in comment)", "linear", {"PR_BODY": UNFILLED, "PR_BRANCH": "x"}, 1),
70-
# --- linear-link: acceptances ---
71-
("linear: issue URL in body", "linear", {"PR_BODY": "https://linear.app/autonominetwork/issue/V2-719/foo"}, 0),
81+
# A bare reference in the body does not link the PR in Linear (V2-1161).
82+
("linear: bare key in body only", "linear", {"PR_BODY": "V2-1161", "PR_BRANCH": "x"}, 1),
83+
("linear: bare issue URL in body only", "linear", {"PR_BODY": "https://linear.app/autonominetwork/issue/V2-719/foo", "PR_BRANCH": "x"}, 1),
84+
("linear: magic word without a key", "linear", {"PR_BODY": "Closes the gap", "PR_BRANCH": "x"}, 1),
85+
("linear: magic word on its own line from the key", "linear", {"PR_BODY": "Closes\n\nV2-1161", "PR_BRANCH": "x"}, 1),
86+
# Linear's linking-only families attach the PR but do not drive the Merged
87+
# transition, so they are not accepted as the closing form.
88+
("linear: 'part of' is linking-only", "linear", {"PR_BODY": "part of V2-1161", "PR_BRANCH": "x"}, 1),
89+
("linear: 'ref' is linking-only", "linear", {"PR_BODY": "ref V2-1161", "PR_BRANCH": "x"}, 1),
90+
("linear: 'towards' is linking-only", "linear", {"PR_BODY": "towards V2-1161", "PR_BRANCH": "x"}, 1),
91+
("linear: 'relates to' is linking-only", "linear", {"PR_BODY": "relates to V2-1161", "PR_BRANCH": "x"}, 1),
92+
("linear: magic word as a word prefix", "linear", {"PR_BODY": "prefix V2-1161", "PR_BRANCH": "x"}, 1),
93+
("linear: magic word as a word suffix", "linear", {"PR_BODY": "fixture V2-1161", "PR_BRANCH": "x"}, 1),
94+
# --- linear-link: acceptances (Linear's full closing set, any tense) ---
95+
("linear: Closes + key in body", "linear", {"PR_BODY": "Closes V2-1161", "PR_BRANCH": "x"}, 0),
96+
("linear: lower-cased magic word", "linear", {"PR_BODY": "closes v2-1161", "PR_BRANCH": "x"}, 0),
97+
("linear: Closes + issue URL in body", "linear", {"PR_BODY": "Closes https://linear.app/autonominetwork/issue/V2-719/foo", "PR_BRANCH": "x"}, 0),
98+
("linear: closing form inside prose", "linear", {"PR_BODY": "This one closes V2-1161 at last.", "PR_BRANCH": "x"}, 0),
99+
("linear: 'linear issue' phrase", "linear", {"PR_BODY": "Linear issue V2-1161", "PR_BRANCH": "x"}, 0),
72100
("linear: key in branch (lowercased)", "linear", {"PR_BRANCH": "chrisoneil/v2-720-ci-check"}, 0),
73101
("linear: key in title", "linear", {"PR_TITLE": "AUTO-42 do the thing", "PR_BRANCH": "x"}, 0),
102+
] + [
103+
# One case per closing magic word Linear documents, capitalised as an author
104+
# would write it — e.g. "Fixed V2-1161" links and closes in Linear, so it must
105+
# pass here too.
106+
(f"linear: '{word}'", "linear",
107+
{"PR_BODY": f"{word.capitalize()} V2-1161", "PR_BRANCH": "x"}, 0)
108+
for word in CLOSING_MAGIC_WORDS
109+
] + [
74110
# --- pr-template: acceptances ---
75111
("template: valid T0 body", "template", {"PR_BASE": "main", "PR_BODY": VALID_BODY}, 0),
76112
("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):
86122
("- [x] T0 — docs / tooling / CI.", "- [x] T2 — behavioural."))}, 1),
87123
("template: no tier checked", "template", {"PR_BASE": "main", "PR_BODY": body_without(
88124
("- [x] T0 — docs / tooling / CI.", "- [ ] T0 — docs / tooling / CI."))}, 1),
125+
("template: bare key under '## Linear issue'", "template", {"PR_BASE": "main", "PR_BODY": body_without(
126+
("- Closes https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template",
127+
"- V2-719"))}, 1),
89128
("template: two tiers checked", "template", {"PR_BASE": "main", "PR_BODY": body_without(
90129
("- [x] T0 — docs / tooling / CI.", "- [x] T0 a\n- [x] T2 b"))}, 1),
91130
]

CLAUDE.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,8 +485,15 @@ description.
485485

486486
- **Fill every field.** Leave nothing blank; if a value isn't determinable, ask
487487
before opening the PR.
488-
- **Link the Linear issue** — an issue key like `V2-123` or a `linear.app` URL.
489-
CI blocks PRs with no linked Linear issue.
488+
- **Link the Linear issue with a closing magic word** — write `Closes V2-123` in
489+
the `## Linear issue` section, one line per issue. Any of Linear's closing
490+
words works, in any tense (`close` / `fix` / `resolve` / `complete` /
491+
`implement`, plus their `-s`, `-d` and `-ing` forms), and the key may be a
492+
`linear.app` issue URL. A bare `V2-123` does **not** link the PR at all, and
493+
the linking-only words (`ref`, `part of`, `towards`, `relates to`) attach it
494+
without driving the Merged transition — CI rejects both. An issue key in the
495+
branch name or PR title also links, but write the closing form anyway; it is
496+
what moves the issue to Merged when the PR lands on `main`.
490497
- **Check exactly one Risk tier box and exactly one Semver impact box.** Propose
491498
them from the change; a human confirms them at review.
492499
- An **ADR link is required for Tier 2/3**.

0 commit comments

Comments
 (0)