forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrelease_notes.py
More file actions
348 lines (286 loc) · 13.4 KB
/
Copy pathrelease_notes.py
File metadata and controls
348 lines (286 loc) · 13.4 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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
#!/usr/bin/env python3
"""Generate a release notes draft from the merges between two refs.
RELEASING.md §7: the notes are derived and then edited, never maintained. A
hand-kept CHANGELOG.md is the half that goes stale, and this repository already
has the thing that makes generation reliable - `pr-issue-link.yml` fails any
pull request that closes no issue, so every merged change carries a linked issue
or an explicit `no-issue` label.
Two halves, split by what touches the world:
* The pure half - `pull_request_numbers`, `linked_issues`, `area_of`,
`hiker_facing`, `render_notes` - takes data and returns text. That is what
`.github/tests/test_release_notes.py` covers, per TESTING.md's preference
for pure functions over end-to-end runs of a script.
* The I/O half - `git log`, and the GitHub API through urllib - is a thin
seam at the bottom of the file. It is not exercised by the suite, so it is
kept small enough to read.
Standard library only, deliberately. The suite this is tested by installs
pytest, PyYAML and ruff, and there is no argument for a release script to be the
thing that adds an HTTP dependency to it.
What this produces is a **draft**. The name, the historical figure and the
paragraph that makes it a release rather than a diff are written by a human on
top of it (RELEASING.md §5, §6) - that is the part worth a person's time, and
the only part.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
API_ROOT = "https://api.github.com"
# The area labels CONTRIBUTING.md defines, in the order they are worth reading:
# what a hiker touches first, what builds the data second, what only a
# contributor sees last.
AREAS = ["client", "backend", "pipeline", "data", "ops", "docs"]
# A change whose labels are entirely within this set is not something a hiker
# can observe. Everything else is offered to the hiker-facing section, including
# a change with no area labels at all - erring toward including is deliberate,
# because an extra line a human deletes costs a moment and a missed one ships a
# release that does not mention what it changed.
INTERNAL_ONLY = {"docs", "ops", "no-issue"}
# Both spellings of a merged pull request. GitHub writes the first for a merge
# commit and the second into the subject of a squash, and this repository has
# used both - so a generator that knew only one would silently produce short
# notes rather than fail.
MERGE_SUBJECT = re.compile(r"^Merge pull request #(\d+) from ")
SQUASH_SUBJECT = re.compile(r"\(#(\d+)\)\s*$")
# The closing keywords GitHub itself honours. A bare `#42` deliberately does not
# match: CONTRIBUTING.md draws the distinction that referring to an issue and
# resolving it are different claims, and `pr-issue-link.yml` enforces it.
CLOSES = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b", re.IGNORECASE)
@dataclass
class Change:
"""One merged pull request, as much of it as the notes need."""
number: int
title: str
labels: list[str] = field(default_factory=list)
issues: list[int] = field(default_factory=list)
@property
def area(self) -> str:
return area_of(self.labels)
def pull_request_numbers(log: str) -> list[int]:
"""Every pull request number in a `git log --format=%s` block, in order, deduplicated."""
numbers: list[int] = []
for line in log.splitlines():
line = line.strip()
match = MERGE_SUBJECT.match(line) or SQUASH_SUBJECT.search(line)
if match:
number = int(match.group(1))
if number not in numbers:
numbers.append(number)
return numbers
def linked_issues(text: str) -> list[int]:
"""The issues a pull request body closes, in order, deduplicated."""
issues: list[int] = []
for match in CLOSES.finditer(text or ""):
number = int(match.group(1))
if number not in issues:
issues.append(number)
return issues
def area_of(labels: list[str]) -> str:
"""The first area label a change carries, or `other` when it carries none."""
for area in AREAS:
if area in labels:
return area
return "other"
def group_by_area(changes: list[Change]) -> dict[str, list[Change]]:
"""Changes bucketed by area, in AREAS order, with empty buckets omitted."""
grouped: dict[str, list[Change]] = {}
for area in [*AREAS, "other"]:
matching = [change for change in changes if change.area == area]
if matching:
grouped[area] = matching
return grouped
def hiker_facing(changes: list[Change]) -> list[Change]:
"""The changes a hiker could notice - everything not purely internal.
The `not change.labels` half is load-bearing rather than defensive: the empty
set is a subset of every set, so an unlabelled change would otherwise be
classified as internal and silently left out of the notes. That is exactly
backwards from the rule above, and it is the direction that under-reports a
release.
"""
return [change for change in changes if not change.labels or not set(change.labels).issubset(INTERNAL_ONLY)]
def slug(name: str) -> str:
"""`Springer Mountain` -> `springer-mountain`, for the notes filename."""
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def _issue_link(number: int, repo: str) -> str:
return f"[#{number}](https://github.com/{repo}/issues/{number})"
def render_notes(
version: str,
name: str,
changes: list[Change],
repo: str,
previous: str | None = None,
data_release: str | None = None,
unvalidated: list[Change] | None = None,
) -> str:
"""The release notes draft. Everything a machine can know, and a TODO everywhere it cannot."""
lines: list[str] = [f"# {version} — {name}", ""]
if previous:
lines += [f"_{len(changes)} changes since {previous}._", ""]
else:
lines += [f"_{len(changes)} changes. The first release._", ""]
lines += [
"<!-- TODO (human): the paragraph that makes this a release rather than a diff.",
f" Why {name} is this release's landmark, and what the release is actually for. -->",
"",
"## Named beside",
"",
"<!-- TODO (human): one figure from the trail's or hiking's history, per RELEASING.md §6.",
" Every claim cited. An invented anecdote here is the same class of defect as a water",
" source in the wrong place - do not write from memory. -->",
"",
"## What changed for a hiker",
"",
]
visible = hiker_facing(changes)
if visible:
lines += [
"<!-- TODO (human): rewrite these in plain language, and delete any a hiker cannot see.",
" Generated from pull request titles, which are written for reviewers. -->",
"",
]
lines += [f"- {change.title}" for change in visible]
else:
lines.append("Nothing a hiker can observe. This release is internal work only.")
lines.append("")
lines += ["## What changed in the repository", ""]
grouped = group_by_area(changes)
if grouped:
for area, items in grouped.items():
lines += [f"### {area}", ""]
for change in items:
closes = ", ".join(_issue_link(issue, repo) for issue in change.issues)
suffix = f" — {closes}" if closes else ""
lines.append(f"- {change.title} ([#{change.number}](https://github.com/{repo}/pull/{change.number})){suffix}")
lines.append("")
else:
lines += ["No merged pull requests in this range.", ""]
lines += ["## Map data", ""]
if data_release:
lines += [f"This build reads the `{data_release}` data release.", ""]
else:
lines += [
"No data release is pinned yet — `DATA_RELEASE` does not exist in the client "
"(pipeline/DATA_RELEASES.md §4 is designed, not built).",
"",
]
lines += [
"## What is not validated",
"",
"<!-- RELEASING.md §8d: this section is never empty, and a release whose author believes",
" it is has not looked. A hiker deciding whether to trust a direction cue is entitled",
" to know the thresholds behind it have never been tested under tree canopy. -->",
"",
]
if unvalidated:
for change in unvalidated:
lines.append(f"- {change.title} ({_issue_link(change.number, repo)})")
else:
lines.append("<!-- TODO (human): no issue carries `needs-field-testing`. Confirm that is true rather than unlabelled. -->")
lines.append("")
lines += [
"## Compatibility",
"",
"<!-- TODO (human): anything a hiker must do — re-download, re-install, sign in again —",
" or the sentence saying nothing. RELEASING.md §8c. -->",
"",
]
return "\n".join(lines).rstrip() + "\n"
# --------------------------------------------------------------------------
# The I/O half. Kept below the line, and kept short.
# --------------------------------------------------------------------------
def _git(*args: str) -> str:
return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout
def _api(path: str, token: str) -> object:
request = urllib.request.Request(
f"{API_ROOT}{path}",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ourhike-release-notes",
},
)
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - fixed host, built above
return json.load(response)
def _fetch_change(number: int, repo: str, token: str) -> Change | None:
try:
payload = _api(f"/repos/{repo}/pulls/{number}", token)
except urllib.error.HTTPError as error:
# A number parsed out of a commit subject that is not a pull request in
# this repository. Worth saying rather than dropping silently, since the
# symptom is otherwise a release note with a change missing from it.
print(f"::warning::Could not read pull request #{number}: {error}", file=sys.stderr)
return None
assert isinstance(payload, dict)
return Change(
number=number,
title=str(payload.get("title", "")).strip(),
labels=[str(label["name"]) for label in payload.get("labels", [])],
issues=linked_issues(str(payload.get("body") or "")),
)
def _fetch_unvalidated(repo: str, token: str) -> list[Change]:
try:
payload = _api(f"/repos/{repo}/issues?state=open&labels=needs-field-testing&per_page=100", token)
except urllib.error.HTTPError as error:
print(f"::warning::Could not list needs-field-testing issues: {error}", file=sys.stderr)
return []
assert isinstance(payload, list)
return [
Change(number=int(item["number"]), title=str(item["title"]).strip())
for item in payload
if isinstance(item, dict) and "pull_request" not in item
]
def _data_release() -> str | None:
"""The dataset this build pins, if the constant DATA_RELEASES.md §4 designs exists yet."""
source = Path("client/src/lib/dataRelease.ts")
if not source.exists():
return None
match = re.search(r"DATA_RELEASE\s*=\s*['\"]([^'\"]+)['\"]", source.read_text(encoding="utf-8"))
return match.group(1) if match else None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True, help="the version being released, e.g. v1.0.0")
parser.add_argument("--name", required=True, help="its landmark name, e.g. 'Springer Mountain'")
parser.add_argument("--previous", help="the previous tag. Defaults to the most recent one, if there is one.")
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", ""), help="owner/name")
parser.add_argument("--out", help="where to write. Defaults to releases/<version>-<slug>.md")
args = parser.parse_args(argv)
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
print("::error::GITHUB_TOKEN is not set, so pull request titles and labels cannot be read.", file=sys.stderr)
return 1
if not args.repo:
print("::error::--repo or GITHUB_REPOSITORY is required.", file=sys.stderr)
return 1
previous = args.previous
if not previous:
tags = _git("tag", "--list", "v*", "--sort=-v:refname").split()
previous = tags[0] if tags else None
span = f"{previous}..HEAD" if previous else "HEAD"
log = _git("log", "--format=%s", span)
numbers = pull_request_numbers(log)
print(f"{len(numbers)} pull requests in {span}.", file=sys.stderr)
changes = [change for change in (_fetch_change(number, args.repo, token) for number in numbers) if change]
notes = render_notes(
version=args.version,
name=args.name,
changes=changes,
repo=args.repo,
previous=previous,
data_release=_data_release(),
unvalidated=_fetch_unvalidated(args.repo, token),
)
out = Path(args.out) if args.out else Path("releases") / f"{args.version}-{slug(args.name)}.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(notes, encoding="utf-8")
print(f"Wrote {out}.", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())