Skip to content

Commit a04cf15

Browse files
authored
Merge pull request #222 from jacderida/chrisoneil/v2-1161-make-the-prs-linear-link-a-real-link-require-a-magic-word-in
ci: require a closing magic word for the Linear link
2 parents baa63dc + 0c47709 commit a04cf15

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
@@ -115,8 +115,15 @@ description.
115115

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

0 commit comments

Comments
 (0)