import os
import re
import sys
LINEAR_TEAM_PREFIXES = ("V2", "AUTO", "REL", "INFRA", "QA")
LINEAR_KEY = re.compile(
r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b", re.IGNORECASE
)
LINEAR_URL = re.compile(
r"linear\.app/[^/\s]+/issue/[A-Za-z][A-Za-z0-9]*-[0-9]+", 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():
ref = linear_ref(env("PR_TITLE"), strip_comments(env("PR_BODY")), env("PR_BRANCH"))
if ref:
ok(f"✅ Linear reference found: {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."
)
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_ref(strip_comments(secs["linear issue"])):
errors.append("'## Linear issue': add the issue key or a linear.app link")
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()