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
17 changes: 15 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
## Linear issue
<!-- REQUIRED. Link the issue: an issue key like V2-123, or a linear.app URL.
CI blocks PRs with no linked Linear issue. -->
<!-- REQUIRED. Use a closing magic word + the issue key, one line per issue:

Closes V2-123

Any of Linear's closing words works, in any tense — close / fix / resolve /
complete / implement, plus their -s, -d and -ing forms, and the phrase
`linear issue`. The key may be a linear.app/<workspace>/issue/<key> URL.

The closing form is what makes Linear attach the PR to the issue and move
the issue to Merged when this lands on main. A bare `V2-123` does NOT link
the PR — Linear ignores it — so CI rejects it. Linear's linking-only words
(`ref`, `part of`, `towards`, `relates to`) do attach the PR but do not
drive the Merged transition, so CI does not accept those either. (An issue
key in the branch name or the PR title also links the PR, but write the
closing form here anyway.) -->

## Risk tier
<!-- Check exactly one. Boundary question: does this change node behavior, the wire
Expand Down
90 changes: 76 additions & 14 deletions .github/scripts/check_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
no-op that passes on rc-* so hotfix/regression PRs are
not forced to carry the full template).

Linear only attaches a PR to an issue in three ways: the issue key in the branch
name, the key in the PR title, or a closing magic word followed by the key in the
PR description ("Closes V2-123"). A *bare* key in the description does not link
the PR — Linear ignores it — so the linear check no longer accepts one (V2-1161).

PR fields are read from the environment (set by the workflow from the event
payload): PR_TITLE, PR_BODY, PR_BRANCH, PR_BASE.

Expand All @@ -28,14 +33,45 @@

# Case-insensitive so it also matches Linear-generated branch names, which are
# lower-cased (e.g. chrisoneil/v2-720-...).
LINEAR_KEY = re.compile(
r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b", re.IGNORECASE
)
LINEAR_KEY_PATTERN = r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b"
LINEAR_KEY = re.compile(LINEAR_KEY_PATTERN, re.IGNORECASE)
# A real Linear issue URL: linear.app/<workspace>/issue/<KEY>[/<slug>]. Constrained
# to the /issue/<key> path so generic pages (linear.app/changelog,
# linear.app/not-an-issue) do not count as a linked issue.
LINEAR_URL = re.compile(
r"linear\.app/[^/\s]+/issue/[A-Za-z][A-Za-z0-9]*-[0-9]+", re.IGNORECASE
LINEAR_URL_PATTERN = r"linear\.app/[^/\s]+/issue/[A-Za-z][A-Za-z0-9]*-[0-9]+"
LINEAR_URL = re.compile(LINEAR_URL_PATTERN, re.IGNORECASE)

# Linear's *closing* magic words, verbatim from https://linear.app/docs/github.
# Only these both attach the PR and drive the issue to Merged when it lands on
# main. Linear's other families — "ref / refs / references", "part of /
# contributes to / toward / towards", "relates to / related to" — attach the PR
# without the status transition, so they are deliberately not accepted here.
MAGIC_WORDS = (
"close", "closes", "closed", "closing",
"fix", "fixes", "fixed", "fixing",
"resolve", "resolves", "resolved", "resolving",
"complete", "completes", "completed", "completing",
"implement", "implements", "implemented", "implementing",
"linear issue",
)
# The five stems, for failure messages — spelling out all 21 forms is unreadable.
MAGIC_WORD_STEMS = ("close", "fix", "resolve", "complete", "implement")
# "<magic word> V2-123" or "<magic word> https://linear.app/<ws>/issue/V2-123/...".
# This is the form the PR template asks for; a bare key does not match. The
# separator is [ \t]+ rather than \s+ so the two halves must sit on the same
# line — 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. Longest alternative
# first so "closes" is not shadowed by "close".
LINEAR_CLOSES = re.compile(
r"\b(?:"
+ "|".join(re.escape(w) for w in sorted(MAGIC_WORDS, key=len, reverse=True))
+ r")[ \t]+(?:<)?(?:https?://)?(?:"
+ LINEAR_URL_PATTERN
+ r"|"
+ LINEAR_KEY_PATTERN
+ r")",
re.IGNORECASE,
)

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

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


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

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

# ADR must be filled explicitly: 'n/a' for T0/T1, a link for T2/T3.
adr = strip_comments(secs.get("adr", "")).strip()
Expand Down
47 changes: 43 additions & 4 deletions .github/scripts/test_check_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

VALID_BODY = """\
## Linear issue
- https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template
- Closes https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template

## Risk tier
- [x] T0 — docs / tooling / CI.
Expand Down Expand Up @@ -52,13 +52,24 @@ def body_without(section_swap):
# Unfilled template: the only Linear-looking token is the example in a comment.
UNFILLED = """\
## Linear issue
<!-- REQUIRED. Link the issue: an issue key like V2-123, or a linear.app URL. -->
<!-- REQUIRED. Use the closing form, one line per issue: Closes V2-123 -->
## 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 ---
Expand All @@ -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),
Expand All @@ -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),
]
Expand Down
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
Loading