import os
import re
import sys
LINEAR_TEAM_PREFIXES = ("V2", "AUTO", "REL", "INFRA", "QA")
LINEAR_KEY_PATTERN = r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b"
LINEAR_KEY = re.compile(LINEAR_KEY_PATTERN, 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)
MAGIC_WORDS = (
"close", "closes", "closed", "closing",
"fix", "fixes", "fixed", "fixing",
"resolve", "resolves", "resolved", "resolving",
"complete", "completes", "completed", "completing",
"implement", "implements", "implemented", "implementing",
"linear issue",
)
MAGIC_WORD_STEMS = ("close", "fix", "resolve", "complete", "implement")
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_HEADINGS = {
"linear issue": "Linear issue",
"risk tier": "Risk tier",
"compatibility": "Compatibility",
"semver impact": "Semver impact",
"test evidence": "Test evidence",
"new dependency": "New dependency",
"adr": "ADR",
"mitigation / rollback": "Mitigation / rollback",
}
def env(name):
return os.environ.get(name, "") or ""
def fail(msg):
print(msg)
sys.exit(1)
def ok(msg):
print(msg)
sys.exit(0)
def strip_comments(text):
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
def linear_ref(*parts):
haystack = "\n".join(parts)
m = LINEAR_URL.search(haystack) or LINEAR_KEY.search(haystack)
return m.group(0) if m else None
def sections(body):
result, current, buf = {}, None, []
for line in body.splitlines():
m = re.match(r"^\s*##\s+(.*?)\s*$", line)
if m:
if current is not None:
result[current] = "\n".join(buf)
current, buf = m.group(1).strip().lower(), []
elif current is not None:
buf.append(line)
if current is not None:
result[current] = "\n".join(buf)
return result
def check_linear():
closes = LINEAR_CLOSES.search(strip_comments(env("PR_BODY")))
if closes:
ok(f"✅ Linear link found in the PR body: {closes.group(0)}")
ref = linear_ref(env("PR_TITLE"), env("PR_BRANCH"))
if ref:
ok(f"✅ Linear link found in the PR title / branch name: {ref}")
fail(
"❌ No linked Linear issue found.\n\n"
"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."
)
def check_template():
base = env("PR_BASE")
if base and base != "main":
ok(f"✅ pr-template not enforced on base '{base}' (main only).")
body = env("PR_BODY")
secs = sections(body)
errors = []
if "risk tier" not in secs or "semver impact" not in secs:
fail(
"❌ PR template not detected.\n\n"
"Your PR description must use .github/PULL_REQUEST_TEMPLATE.md (the\n"
"'## Risk tier' and '## Semver impact' sections are missing). Copy the\n"
"template into the PR body and fill every field."
)
for heading in CANONICAL_HEADINGS:
if heading not in secs:
errors.append(f"missing section: ## {CANONICAL_HEADINGS[heading]}")
tiers = re.findall(
r"^\s*-\s*\[[xX]\]\s*(T[0-3])\b", secs.get("risk tier", ""), re.MULTILINE
)
if len(tiers) != 1:
errors.append(
f"Risk tier: check exactly one box (found {len(tiers)} checked)"
)
tier = tiers[0] if len(tiers) == 1 else None
semver = re.findall(
r"^\s*-\s*\[[xX]\]\s*(breaking|feature|fix)\b",
secs.get("semver impact", ""),
re.MULTILINE | re.IGNORECASE,
)
if len(semver) != 1:
errors.append(
f"Semver impact: check exactly one box (found {len(semver)} checked)"
)
for heading in ("test evidence", "new dependency", "mitigation / rollback"):
if heading in secs and not strip_comments(secs[heading]).strip():
errors.append(f"'## {CANONICAL_HEADINGS[heading]}' is empty")
comp = strip_comments(secs.get("compatibility", ""))
for axis in ("Wire", "Storage", "API"):
if not re.search(rf"^[ \t]*-[ \t]*{axis}[ \t]*:[ \t]*\S", comp, re.MULTILINE):
errors.append(
f"'## Compatibility': fill in {axis} (use 'none' if not applicable)"
)
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 = strip_comments(secs.get("adr", "")).strip()
if not adr:
errors.append(
"'## ADR' is empty: write 'n/a' for Tier 0/1, or an ADR link for Tier 2/3"
)
elif tier in ("T2", "T3") and not re.search(r"https?://", adr):
errors.append(f"ADR is required for {tier}: add an ADR link in '## ADR'")
if errors:
fail(
"❌ PR template incomplete:\n"
+ "\n".join(f" - {e}" for e in errors)
+ "\n\nFill in .github/PULL_REQUEST_TEMPLATE.md completely and update the PR."
)
ok(f"✅ PR template complete (tier {tier}).")
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else ""
if mode == "linear":
check_linear()
elif mode == "template":
check_template()
else:
fail(f"usage: check_pr.py [linear|template] (got: {mode!r})")
if __name__ == "__main__":
main()