Repository navigation
Expand file tree
/
Copy pathenforce-pr-template.py
More file actions
executable file
·312 lines (265 loc) · 11 KB
/
Copy pathenforce-pr-template.py
File metadata and controls
executable file
·312 lines (265 loc) · 11 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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
COMMENT_MARKER = "<!-- umbriel-pr-template-enforcement -->"
TEMPLATE_URL = (
"https://github.com/noctalia-dev/umbriel/blob/main/.github/PULL_REQUEST_TEMPLATE.md"
)
# Only structure that states an obligation is required. Sections that merely offer
# context (Related Issue, Screenshots / Videos, Manual Coverage, Additional Notes)
# may be filled in, left empty, or deleted.
REQUIRED_HEADINGS = (
"## Summary",
"## Motivation",
"## Type of Change",
"## Testing",
"## Checklist",
)
# Unchecked change types are ballot options, not obligations: a description may keep
# only the ones that apply. A pull request ready for review needs exactly one primary type
# checked; "Breaking change" qualifies it and never counts on its own.
TYPE_CHANGE_ITEMS = (
"Bug fix",
"New feature",
"Breaking change",
"Refactoring",
"Build / packaging",
"Documentation only",
)
PRIMARY_CHANGE_TYPES = tuple(item for item in TYPE_CHANGE_ITEMS if item != "Breaking change")
CHANGE_TYPE_REQUIREMENT = "exactly one checked change type other than Breaking change"
MANDATORY_CHECKLIST_ITEMS = (
"This PR is ready for review, or it is marked as Draft.",
"This change fits `SCOPE.md`, or its scope was agreed in an issue or on Discord first.",
"I read and followed the relevant guidance in `CONTRIBUTING.md`.",
"I ran `just format`, or this PR has no C++, shader, or Markdown changes.",
"I ran the relevant build, test, lint, or verification commands, or explained why they were not run.",
"I functionally verified compositor behavior where automated checks are insufficient.",
"I self-reviewed the changes.",
"I checked for new warnings or errors.",
"I updated `docs/` and `examples/config.toml`, or this PR does not change user-facing configuration or behavior.",
"I used canonical names for config keys, IPC actions, paths, and identifiers.",
)
CONVERTED_INTRO = f"""{COMMENT_MARKER}
This pull request was converted to a draft because its description is missing required
parts of [the pull request template]({TEMPLATE_URL}).
Missing:
"""
DRAFT_INTRO = f"""{COMMENT_MARKER}
This draft pull request is missing required parts of
[the pull request template]({TEMPLATE_URL}).
Missing:
"""
OUTRO = """
Add the items above to the description, keeping their exact wording, then mark the pull
request ready for review. That re-runs this check. Draft pull requests may leave boxes
unchecked. Before a pull request is ready for review, check exactly one change type (add
Breaking change alongside it when it applies) and check every item under Checklist. A pull
request should contain one feature, fix, or refactor; split unrelated changes.
Sections that only offer context may be deleted; nothing else about this pull request was
changed.
"""
RESOLVED_COMMENT = f"""{COMMENT_MARKER}
The description now contains the required template structure.
"""
def build_enforcement_comment(missing: list[str], *, converted: bool) -> str:
bullets = "".join(f"- {item}\n" for item in missing)
intro = CONVERTED_INTRO if converted else DRAFT_INTRO
return f"{intro}{bullets}{OUTRO}"
def checklist_state(normalized_body: str, item: str) -> str | None:
for bullet in ("-", "*"):
for state in (" ", "x", "X"):
if f"{bullet} [{state}] {item}" in normalized_body:
return state
return None
def missing_requirements(body: object, *, require_completed: bool = False) -> list[str]:
if not isinstance(body, str):
body = ""
lines = {line.strip() for line in body.splitlines()}
normalized_body = " ".join(body.split())
missing: list[str] = []
for heading in REQUIRED_HEADINGS:
if heading not in lines:
missing.append(f"the `{heading}` heading")
states = {
item: checklist_state(normalized_body, item)
for item in MANDATORY_CHECKLIST_ITEMS
}
for item, state in states.items():
if state is None:
missing.append(f"the checklist entry: {item}")
if not require_completed:
return missing
checked_primary_types = [
item
for item in PRIMARY_CHANGE_TYPES
if checklist_state(normalized_body, item) in ("x", "X")
]
if len(checked_primary_types) != 1:
missing.append(CHANGE_TYPE_REQUIREMENT)
for item in MANDATORY_CHECKLIST_ITEMS:
if states[item] == " ":
missing.append(f"the checked checklist entry: {item}")
return missing
def github_request(
url: str,
token: str,
*,
method: str = "GET",
payload: dict[str, object] | None = None,
) -> Any:
data = None if payload is None else json.dumps(payload).encode()
request = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": "umbriel-pr-template-enforcement",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
response_body = response.read()
return json.loads(response_body) if response_body else None
def convert_to_draft(pull_request_url: str, node_id: str, token: str) -> None:
api_root, separator, _ = pull_request_url.partition("/repos/")
if not separator:
raise ValueError("pull request URL does not point at a GitHub API host")
result = github_request(
f"{api_root}/graphql",
token,
method="POST",
payload={
"query": (
"mutation($id:ID!)"
"{convertPullRequestToDraft(input:{pullRequestId:$id})"
"{pullRequest{isDraft}}}"
),
"variables": {"id": node_id},
},
)
if not isinstance(result, dict):
raise RuntimeError("GitHub returned an invalid draft conversion response")
errors = result.get("errors")
if errors:
raise RuntimeError(f"GitHub refused to convert the pull request to a draft: {errors}")
converted = result.get("data", {}).get("convertPullRequestToDraft", {})
pull_request = converted.get("pullRequest") if isinstance(converted, dict) else None
if not isinstance(pull_request, dict) or pull_request.get("isDraft") is not True:
raise RuntimeError("GitHub did not confirm that the pull request became a draft")
# The workflow's built-in token authors comments as this account. Marker comments left by
# any other author (such as the Noctalia-CI machine account, whose comments GitHub hides)
# would stay invisible even if edited, so they are deleted and the comment posted afresh.
COMMENT_AUTHOR = "github-actions[bot]"
def enforcement_comments(issue_url: str, token: str) -> list[dict[str, Any]]:
page = 1
found: list[dict[str, Any]] = []
while True:
comments = github_request(
f"{issue_url}/comments?per_page=100&page={page}",
token,
)
if not isinstance(comments, list):
raise RuntimeError("GitHub returned an invalid pull request comment list")
for comment in comments:
if isinstance(comment, dict) and COMMENT_MARKER in str(comment.get("body", "")):
found.append(comment)
if len(comments) < 100:
return found
page += 1
def comment_api_url(comment: dict[str, Any]) -> str:
comment_url = comment.get("url")
if not isinstance(comment_url, str):
raise RuntimeError("GitHub comment is missing its API URL")
return comment_url
def is_own_comment(comment: dict[str, Any]) -> bool:
user = comment.get("user")
return isinstance(user, dict) and user.get("login") == COMMENT_AUTHOR
def sync_enforcement_comment(issue_url: str, token: str, comment: str) -> None:
"""Keep exactly one bot comment on the pull request, rewritten in place.
Marker comments left by another author are deleted, then the comment is posted afresh.
"""
existing: dict[str, Any] | None = None
foreign: list[dict[str, Any]] = []
for candidate in enforcement_comments(issue_url, token):
if is_own_comment(candidate):
existing = candidate
else:
foreign.append(candidate)
for stale in foreign:
github_request(comment_api_url(stale), token, method="DELETE")
if existing is None:
if comment == RESOLVED_COMMENT:
return
github_request(
f"{issue_url}/comments",
token,
method="POST",
payload={"body": comment},
)
return
if str(existing.get("body", "")) == comment:
return
github_request(comment_api_url(existing), token, method="PATCH", payload={"body": comment})
def enforce(event: dict[str, object], token: str) -> list[str]:
pull_request = event.get("pull_request")
if not isinstance(pull_request, dict):
raise ValueError("event does not contain a pull_request object")
is_draft = pull_request.get("draft") is True
missing = missing_requirements(
pull_request.get("body"),
require_completed=not is_draft,
)
issue_url = pull_request.get("issue_url")
pull_request_url = pull_request.get("url")
node_id = pull_request.get("node_id")
if not isinstance(issue_url, str) or not isinstance(pull_request_url, str):
raise ValueError("pull request event is missing GitHub API URLs")
if not token:
raise ValueError("GITHUB_TOKEN is required to report on a pull request")
if not missing:
sync_enforcement_comment(issue_url, token, RESOLVED_COMMENT)
return []
comment = build_enforcement_comment(missing, converted=not is_draft)
if is_draft:
sync_enforcement_comment(issue_url, token, comment)
else:
if not isinstance(node_id, str):
raise ValueError("pull request event is missing its node ID")
convert_to_draft(pull_request_url, node_id, token)
github_request(
f"{issue_url}/comments",
token,
method="POST",
payload={"body": comment},
)
return missing
def main(argv: list[str]) -> int:
event_path = Path(argv[1] if len(argv) > 1 else os.environ["GITHUB_EVENT_PATH"])
try:
event = json.loads(event_path.read_text())
if not isinstance(event, dict):
raise ValueError("GitHub event payload must be a JSON object")
missing = enforce(event, os.environ.get("GITHUB_TOKEN", ""))
except (OSError, ValueError, RuntimeError, urllib.error.URLError) as error:
print(f"::error title=PR template enforcement failed::{error}")
return 1
if missing:
print(
"::error title=Pull request description is missing required template content::"
+ "; ".join(missing)
)
return 1
print("Pull request description retains the required template structure.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))