-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathcheck_pr.py
More file actions
executable file
·199 lines (162 loc) · 7.09 KB
/
Copy pathcheck_pr.py
File metadata and controls
executable file
·199 lines (162 loc) · 7.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
#!/usr/bin/env python3
"""Release-process PR checks.
Two modes, each wired to its own required status check:
check_pr.py linear V2-720 — require a linked Linear issue on every PR
(main + rc-*).
check_pr.py template V2-719 — require the standard PR template to be present
and fully filled (enforced on PRs targeting main; a
no-op that passes on rc-* so hotfix/regression PRs are
not forced to carry the full template).
PR fields are read from the environment (set by the workflow from the event
payload): PR_TITLE, PR_BODY, PR_BRANCH, PR_BASE.
This file is duplicated verbatim across the six crate repos; keep them
identical when updating.
"""
import os
import re
import sys
# Known Linear team keys (the issue-key prefixes). A Linear reference is a URL
# under linear.app OR an issue key with one of these prefixes — this is what
# keeps unrelated technical tokens like "UTF-8" / "SHA-256" / "RFC-123" from
# satisfying the gate. Add a new team's key here when a team is created.
LINEAR_TEAM_PREFIXES = ("V2", "AUTO", "REL", "INFRA", "QA")
# 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_URL = re.compile(r"linear\.app/", re.IGNORECASE)
# Canonical section headings, exactly as they appear in the template, keyed by
# their lower-cased form. Used so failure messages name the real heading.
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):
"""Remove HTML comments so template scaffolding and its examples (e.g. the
'V2-123' hint in the Linear-issue comment) never count as real content."""
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
def linear_ref(*parts):
"""Return the first Linear URL/key found in the given (comment-free) 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):
"""Split a markdown body into {heading-lowercased: text-until-next-##}."""
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():
# 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"))
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)
+ ") in the PR title, body, or branch name, or a linear.app issue URL in\n"
"the body. 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 = []
# The Risk tier / Semver impact headings are the sentinels that the template
# is actually in use.
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]}")
# Exactly one Risk tier box checked.
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
# Exactly one Semver impact box checked.
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)"
)
# Free-text sections that must not be empty.
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")
# Compatibility: every axis must carry a value (use 'none' where N/A).
# Use [ \t] rather than \s after the colon so an empty axis cannot "borrow"
# the next line's content across a newline.
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)"
)
# 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")
# ADR must be filled explicitly: 'n/a' for T0/T1, a link for T2/T3.
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()