/
/
/
1"""
2CI check: verify a pull request title and description are fit to be release-noted.
3
4Release notes are generated verbatim from pull request titles, so a conventional-commit subject
5has to be rewritten by hand at release time. The template is not paperwork either: its sections
6carry the change description reviewers read, the "Types of changes" box the release-notes label
7is derived from, and a checklist recording the author's own verification. A body that replaced
8the template (by hand or by an AI agent) drops all of that silently.
9
10Both are reported together, as one list, so the author gets a single message.
11
12Reads the body from stdin and the title from ``--title``.
13
14Usage:
15 gh pr view 1234 --json title,body --jq .body |
16 uv run -m scripts.check_pr_metadata --title "$(gh pr view 1234 --json title --jq .title)"
17"""
18
19from __future__ import annotations
20
21import argparse
22import re
23import sys
24from pathlib import Path
25
26# ruff: noqa: T201
27
28DEFAULT_TEMPLATE = ".github/PULL_REQUEST_TEMPLATE.md"
29
30# Checklist items every pull request must tick. The remaining template items are conditional
31# (companion model/frontend pull requests, documentation) and can never be required. Each entry
32# must match exactly one template item â tests/scripts/test_check_pr_metadata.py asserts that,
33# so renaming an item in the template surfaces there instead of failing every pull request.
34REQUIRED_CHECKLIST_ITEMS = (
35 "The code change is tested and works locally",
36 "`pre-commit run --all-files` passes",
37 "`pytest` passes",
38 "AI Policy",
39)
40
41CONVENTIONAL_TYPES = (
42 "build",
43 "chore",
44 "ci",
45 "deps",
46 "docs",
47 "feat",
48 "feature",
49 "fix",
50 "perf",
51 "refactor",
52 "revert",
53 "style",
54 "test",
55 "tests",
56)
57# A conventional-commit subject: an optional scope and "!" between the type and the colon.
58CONVENTIONAL_PREFIX_RE = re.compile(
59 rf"^\s*(?:{'|'.join(CONVENTIONAL_TYPES)})(\([^)]*\))?!?\s*:",
60 re.IGNORECASE,
61)
62
63HEADING_RE = re.compile(r"^ {0,3}(#{1,6})\s+(?P<text>.+?)\s*#*\s*$")
64TASK_ITEM_RE = re.compile(r"^\s*[-*]\s*\[(?P<tick>[ xX])\]\s*(?P<text>.*?)\s*$")
65HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
66
67
68def template_headings(template: str) -> list[str]:
69 """Return the template's heading lines, in template order."""
70 return [line.strip() for line in template.splitlines() if HEADING_RE.match(line)]
71
72
73def checklist_items(template: str) -> list[str]:
74 """Return the task list item texts under the template's "Checklist" heading."""
75 return [text for _, text in _checklist_entries(template)]
76
77
78def check_title(title: str) -> list[str]:
79 """Return the problems found in a pull request title, empty when it passes."""
80 if not title.strip():
81 return ["The pull request title is empty."]
82 if not (prefix := CONVENTIONAL_PREFIX_RE.match(title)):
83 return []
84 return [
85 f"Title starts with the conventional-commit prefix `{prefix.group().strip()}` â write the "
86 'title as the user-facing change instead, for example "Fix Sonos hanging after reconnect".'
87 ]
88
89
90def check_description(body: str, template: str) -> list[str]:
91 """
92 Return the template problems found in a pull request body, empty when it passes.
93
94 :param body: The pull request description.
95 :param template: Contents of the pull request template to check against.
96 """
97 if not body.strip():
98 return ["The pull request description is empty."]
99
100 problems = [
101 f"Missing template section: {heading}"
102 for heading in template_headings(template)
103 if not _has_heading(body, heading)
104 ]
105 items = checklist_items(template)
106 problems.extend(
107 f"Checklist item not ticked: {_label(required, items)}"
108 for required in REQUIRED_CHECKLIST_ITEMS
109 if not _is_ticked(body, required)
110 )
111 return problems
112
113
114def check_pull_request(title: str, body: str, template: str) -> list[str]:
115 """
116 Return every title and description problem as one list, empty when the pull request passes.
117
118 :param title: The pull request title.
119 :param body: The pull request description.
120 :param template: Contents of the pull request template to check against.
121 """
122 return check_title(title) + check_description(body, template)
123
124
125def main(argv: list[str] | None = None) -> int:
126 """Check the title and the body on stdin, and return the process exit code."""
127 parser = argparse.ArgumentParser(description=__doc__)
128 parser.add_argument("--title", default="", help="The pull request title.")
129 parser.add_argument(
130 "--template", type=Path, default=Path(DEFAULT_TEMPLATE), help="Template to check against."
131 )
132 args = parser.parse_args(argv)
133 template = args.template.read_text(encoding="utf-8")
134
135 if not (problems := check_pull_request(args.title, sys.stdin.read(), template)):
136 print("The title and description are good to go.")
137 return 0
138
139 print("The title or description of this pull request needs a fix:\n")
140 for problem in problems:
141 print(f"- {problem}")
142 print(
143 "\nRelease notes are generated from the title, and the template carries what reviewers "
144 "need, so please edit them before this is reviewed."
145 )
146 summary = "; ".join(problems)
147 print(
148 f"::error title=Pull request title or description needs a fix::{summary}", file=sys.stderr
149 )
150 return 1
151
152
153def _normalize(text: str) -> str:
154 """Return text stripped of backticks and casing differences for tolerant matching."""
155 return re.sub(r"\s+", " ", text.replace("`", "")).strip().casefold()
156
157
158def _strip_comments(text: str) -> str:
159 """Return text without HTML comments, so commented-out lines never count as present."""
160 return HTML_COMMENT_RE.sub("", text)
161
162
163def _has_heading(body: str, heading: str) -> bool:
164 """Return whether the body carries the given template heading, at any heading level."""
165 match = HEADING_RE.match(heading)
166 wanted = _normalize(match["text"] if match else heading)
167 return any(
168 _normalize(match["text"]) == wanted
169 for line in _strip_comments(body).splitlines()
170 if (match := HEADING_RE.match(line))
171 )
172
173
174def _checklist_entries(text: str) -> list[tuple[bool, str]]:
175 """Return ``(ticked, text)`` for each task list item under the "Checklist" heading."""
176 entries: list[tuple[bool, str]] = []
177 in_checklist = False
178 for line in _strip_comments(text).splitlines():
179 if heading := HEADING_RE.match(line):
180 in_checklist = _normalize(heading["text"]) == "checklist"
181 continue
182 if in_checklist and (item := TASK_ITEM_RE.match(line)):
183 entries.append((item["tick"] in "xX", item["text"]))
184 return entries
185
186
187def _is_ticked(body: str, required: str) -> bool:
188 """Return whether the body's checklist section ticks the item matching a required text."""
189 wanted = _normalize(required)
190 # Scoped to the checklist section on purpose: a ticked line elsewhere in the body must not
191 # satisfy a requirement the checklist itself leaves open.
192 return any(ticked and wanted in _normalize(text) for ticked, text in _checklist_entries(body))
193
194
195def _label(required: str, items: list[str]) -> str:
196 """Return the template's own wording for a required item, falling back to its key text."""
197 wanted = _normalize(required)
198 return next((item for item in items if wanted in _normalize(item)), required)
199
200
201if __name__ == "__main__":
202 sys.exit(main())
203