Skip to content

Commit 4e1e683

Browse files
committed
pkg_in_pipe: generate a draft of the release post from the report
Add release_post.py, which writes the "What changed" and "Versions" sections of the XCP-ng release post from the json report, with the change descriptions for the users taken from the related pull requests. Signed-off-by: Gaëtan Lehmann <gaetan.lehmann@vates.tech>
1 parent 1d6fe54 commit 4e1e683

2 files changed

Lines changed: 365 additions & 0 deletions

File tree

scripts/pkg_in_pipe/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,51 @@ The json report can be validated against its schema with:
2828
python -m jsonschema -i report.json pkg_in_pipe.schema.json
2929
```
3030

31+
# Release post generator
32+
33+
The `release_post.py` script generates the whole XCP-ng release post
34+
from the json report. For each package of a given koji tag, it fetches the descriptions of the
35+
related pull requests from github and prints the `Explain the change to users` section of those
36+
descriptions.
37+
38+
It needs the `pydantic`, `requests` and `tqdm` python modules.
39+
An optional `--github-token` option (or `GITHUB_TOKEN` environment variable) is used to
40+
avoid the github api rate limits.
41+
42+
```sh
43+
pkg_in_pipe --json-output report.json
44+
release_post.py --report report.json
45+
```
46+
47+
The release post is written on the standard output, as a full post template: the "What changed"
48+
section contains one item per package with the `Explain the change to users` section of the pull
49+
request descriptions, printed verbatim. The post writer can then
50+
reorganize the items into the usual categories. The "Versions"
51+
section lists every package of the tag with its version, showing the previously released
52+
version as well when the report knows it (the `previous_nvr` field, the newest build of the
53+
package in the updates or base tag). The `--version` option overrides the version number of
54+
the post, otherwise it is derived from the tag (e.g. `v8.3-ci` gives 8.3).
55+
56+
The verbatim version is printed as a single list item: a one line section is written right
57+
after the package name, a multiline section is written entirely on the following lines,
58+
indented under the package name (blank lines are preserved). The HTML comments left in the
59+
template of the pull request descriptions, and the blank lines around them, are removed.
60+
A package without that section still gets an entry, with an empty description, and a
61+
warning is printed on the standard error. For example:
62+
63+
```markdown
64+
- `xo-lite`: * Update the UiTitle component to use the one from web-core (PR #9869)
65+
66+
- `amd-microcode`:
67+
Update to 2026-05-19 drop as redistributed by XenServer
68+
Updated CPUs:
69+
BRH-C1 00b00f21: 2025-10-17, rev 0b002161 -> 2025-10-17, rev 0b002162
70+
```
71+
72+
The pull request descriptions are cached (same cache as the report
73+
generator, in `/tmp/pkg_in_pipe.cache`, 24 hours retention). Use `--cache` to use another cache
74+
path and `--re-cache` to refresh the cache.
75+
3176
# Run in docker
3277

3378
Before running in docker, the docker image must be built with:
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
#!/usr/bin/env python
2+
"""Generate the package update section of the XCP-ng release post.
3+
4+
Read the json report generated by pkg_in_pipe.py and, for each package of a
5+
given koji tag, print the "Explain the change to users" sections of the pull
6+
requests related to the package builds.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import os
13+
import re
14+
import signal
15+
import sys
16+
from collections import defaultdict
17+
from datetime import datetime
18+
from pathlib import Path
19+
from string import Template
20+
from textwrap import dedent
21+
from typing import Literal, cast
22+
23+
import diskcache # type: ignore[import-untyped]
24+
import requests
25+
from pydantic import BaseModel
26+
from tqdm import tqdm
27+
28+
29+
class Warnings(BaseModel):
30+
plane: bool
31+
github: bool
32+
33+
34+
class Issue(BaseModel):
35+
sequence_id: int
36+
url: str
37+
milestones: list[str]
38+
39+
40+
class PullRequest(BaseModel):
41+
number: int
42+
title: str
43+
url: str
44+
linked: bool
45+
46+
47+
class Build(BaseModel):
48+
nvr: str
49+
previous_nvr: str | None = None
50+
package: str
51+
url: str
52+
built_by: str
53+
maintained_by: str | None
54+
issues: list[Issue]
55+
pull_requests: list[PullRequest]
56+
build_linked: bool
57+
58+
59+
class TagReport(BaseModel):
60+
tag: str
61+
builds: list[Build]
62+
63+
64+
class Report(BaseModel):
65+
generated_at: datetime
66+
generated_info: str | None
67+
warnings: Warnings
68+
error: Literal['koji', 'unknown'] | None
69+
tags: list[TagReport]
70+
71+
72+
PR_URL_RE = re.compile(r'^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)$')
73+
74+
RETENTION_TIME = 24 * 60 * 60 # 24 hours
75+
76+
77+
def find_tag_report(report: Report, tag: str) -> TagReport:
78+
for tag_report in report.tags:
79+
if tag_report.tag == tag:
80+
return tag_report
81+
raise SystemExit(f'error: the tag {tag} is not present in the report')
82+
83+
84+
def prs_by_package(tag_report: TagReport) -> dict[str, list[PullRequest]]:
85+
prs_by_package: dict[str, list[PullRequest]] = defaultdict(list)
86+
for build in tag_report.builds:
87+
for pr in build.pull_requests:
88+
if all(existing.url != pr.url for existing in prs_by_package[build.package]):
89+
prs_by_package[build.package].append(pr)
90+
return dict(prs_by_package)
91+
92+
93+
def fetch_pr_description(
94+
url: str, token: str | None, re_cache: bool, cache: diskcache.Cache
95+
) -> str | None:
96+
cache_key = f'pr-body-1-{url}'
97+
if not re_cache and cache_key in cache:
98+
return cast(str | None, cache[cache_key])
99+
match = PR_URL_RE.fullmatch(url)
100+
if match is None:
101+
raise RuntimeError(f'not a github pull request url: {url}')
102+
owner, repo, number = cast(tuple[str, str, str], match.groups())
103+
headers = {'Accept': 'application/vnd.github+json'}
104+
if token:
105+
headers['Authorization'] = f'Bearer {token}'
106+
response = requests.get(f'https://api.github.com/repos/{owner}/{repo}/pulls/{number}', headers=headers, timeout=30)
107+
if response.status_code != 200:
108+
raise RuntimeError(f'got a {response.status_code} response')
109+
body = cast(str | None, response.json().get('body'))
110+
cache.set(cache_key, body, expire=RETENTION_TIME)
111+
return body
112+
113+
114+
def fetch_pr_descriptions(
115+
prs: list[PullRequest], token: str | None, re_cache: bool, cache: diskcache.Cache
116+
) -> list[tuple[PullRequest, str]]:
117+
descriptions: list[tuple[PullRequest, str]] = []
118+
for pr in prs:
119+
try:
120+
description = fetch_pr_description(pr.url, token, re_cache, cache)
121+
except (RuntimeError, requests.RequestException) as e:
122+
print(f'warning: could not fetch the description of {pr.url}: {e}', file=sys.stderr)
123+
continue
124+
if description is None:
125+
print(f'warning: the pull request {pr.url} has no description', file=sys.stderr)
126+
continue
127+
descriptions.append((pr, description))
128+
return descriptions
129+
130+
131+
USER_SECTION_RE = re.compile(r'(?i)^#{1,6}\s*Explain the change to users\s*$')
132+
HEADING_RE = re.compile(r'^#{1,6}(\s|$)')
133+
CODE_FENCE_RE = re.compile(r'^\s*(```+|~~~+)')
134+
135+
136+
def extract_user_section(description: str) -> list[str] | None:
137+
"""Return the lines of the "Explain the change to users" section of a pull request description.
138+
139+
The section is returned verbatim, except for the HTML comments (template boilerplate)
140+
and the blank lines around them, which are removed.
141+
"""
142+
in_code = False
143+
section: list[str] | None = None
144+
for line in description.splitlines():
145+
fence = CODE_FENCE_RE.match(line)
146+
if fence is not None:
147+
in_code = not in_code
148+
if section is not None:
149+
if not in_code and HEADING_RE.match(line):
150+
break
151+
section.append(line)
152+
elif not in_code and USER_SECTION_RE.match(line):
153+
section = []
154+
if section is None:
155+
return None
156+
section = strip_html_comments(section)
157+
while section and not section[0].strip():
158+
section.pop(0)
159+
while section and not section[-1].strip():
160+
section.pop()
161+
return section or None
162+
163+
164+
def strip_html_comments(lines: list[str]) -> list[str]:
165+
"""Remove the HTML comments (template boilerplate) and the blank lines around them."""
166+
res: list[str] = []
167+
in_comment = False
168+
skip_blanks = False
169+
for line in lines:
170+
if in_comment:
171+
if '-->' in line:
172+
in_comment = False
173+
skip_blanks = True
174+
continue
175+
if '<!--' in line:
176+
in_comment = '-->' not in line
177+
skip_blanks = True
178+
while res and not res[-1].strip():
179+
res.pop()
180+
continue
181+
if skip_blanks and not line.strip():
182+
continue
183+
skip_blanks = False
184+
res.append(line)
185+
while res and not res[-1].strip():
186+
res.pop()
187+
return res
188+
189+
190+
def warn(pbar: tqdm, message: str) -> None:
191+
pbar.clear()
192+
print(f'warning: {message}', file=sys.stderr)
193+
194+
195+
def collect_sections(descriptions: list[tuple[PullRequest, str]], pbar: tqdm) -> list[str]:
196+
lines: list[str] = []
197+
for pr, description in descriptions:
198+
section = extract_user_section(description)
199+
if section is None:
200+
warn(pbar, f'no "Explain the change to users" section in {pr.url}')
201+
continue
202+
lines.extend(section)
203+
return lines
204+
205+
206+
def verbatim_lines(package: str, lines: list[str]) -> list[str]:
207+
if len(lines) > 1:
208+
return [f'- `{package}`:'] + [f'\t{line}' if line else '' for line in lines]
209+
if lines:
210+
return [f'- `{package}`: {lines[0]}']
211+
return [f'- `{package}`:']
212+
213+
214+
def versions_by_package(tag_report: TagReport) -> list[tuple[str, str | None, str]]:
215+
"""Return the latest build of each package, as (package, previous_nvr, nvr), sorted by package name.
216+
217+
The report lists the builds of a tag newest first, so the first occurrence of a package is its latest build.
218+
"""
219+
latest: dict[str, tuple[str | None, str]] = {}
220+
for build in tag_report.builds:
221+
latest.setdefault(build.package, (build.previous_nvr, build.nvr))
222+
return [(package, previous_nvr, nvr) for package, (previous_nvr, nvr) in sorted(latest.items())]
223+
224+
225+
def version_release(package: str, nvr: str) -> str:
226+
"""Return the version-release part of a package nvr."""
227+
return nvr.removeprefix(package + '-')
228+
229+
230+
def format_versions(versions: list[tuple[str, str | None, str]]) -> str:
231+
lines = []
232+
for package, previous_nvr, nvr in versions:
233+
new_version = version_release(package, nvr)
234+
item = f'{version_release(package, previous_nvr)} -> {new_version}' if previous_nvr is not None else new_version
235+
lines.append(f'* `{package}`: {item}')
236+
return '\n'.join(lines)
237+
238+
239+
POST_TEMPLATE = dedent('''\
240+
# New maintenance update candidates for XCP-ng $version LTS
241+
242+
This batch of updates contains mostly fixes, tools version update, a some improvements.
243+
244+
## What changed
245+
246+
$what_changed
247+
248+
## Versions
249+
250+
$versions
251+
252+
## Test on XCP-ng $version
253+
254+
```bash
255+
yum clean metadata --enablerepo=xcp-ng-testing,xcp-ng-candidates
256+
yum update --enablerepo=xcp-ng-testing,xcp-ng-candidates
257+
reboot
258+
```
259+
260+
The usual update rules apply: pool coordinator first, etc.
261+
262+
## What to test
263+
264+
As usual, normal use and anything else you want to test.
265+
266+
## Test window before official release of the updates
267+
268+
**X days**
269+
270+
We would like to thank users who shared feedback since our last call for testing:
271+
''')
272+
273+
274+
def format_post(what_changed: str, versions: str, version: str) -> str:
275+
return Template(POST_TEMPLATE).substitute(what_changed=what_changed, versions=versions, version=version)
276+
277+
278+
def parse_args() -> argparse.Namespace:
279+
parser = argparse.ArgumentParser(description='Generate the package update section of the XCP-ng release post')
280+
parser.add_argument('--report', help='The json report generated by pkg_in_pipe.py', default='report.json')
281+
parser.add_argument('--tag', help='The koji tag to include in the release post', default='v8.3-ci')
282+
parser.add_argument('--version', help='The XCP-ng version of the release post, e.g. 8.3', default=None)
283+
parser.add_argument('--cache', help='The cache path', default='/tmp/pkg_in_pipe.cache')
284+
parser.add_argument('--re-cache', help='Refresh the cache', action='store_true')
285+
parser.add_argument(
286+
'--github-token', help='The token used to access the Github api', default=os.environ.get('GITHUB_TOKEN')
287+
)
288+
return parser.parse_args()
289+
290+
291+
def main() -> None:
292+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
293+
args = parse_args()
294+
cache = diskcache.Cache(args.cache)
295+
report = Report.model_validate_json(Path(args.report).read_text())
296+
tag_report = find_tag_report(report, args.tag)
297+
what_changed: list[str] = []
298+
with tqdm(
299+
sorted(prs_by_package(tag_report).items()),
300+
desc='packages',
301+
unit='package',
302+
file=sys.stderr,
303+
leave=False,
304+
) as pbar:
305+
for package, prs in pbar:
306+
descriptions = fetch_pr_descriptions(prs, args.github_token, args.re_cache, cache)
307+
if not descriptions:
308+
warn(pbar, f'no description available for the pull requests of {package}, skipping it')
309+
continue
310+
lines = collect_sections(descriptions, pbar)
311+
if not lines:
312+
warn(pbar, f'no "Explain the change to users" section available for the pull requests of {package}')
313+
what_changed.extend(verbatim_lines(package, lines))
314+
what_changed.append('')
315+
version = args.version or args.tag[1:].split('-')[0]
316+
print(format_post('\n'.join(what_changed).rstrip(), format_versions(versions_by_package(tag_report)), version))
317+
318+
319+
if __name__ == '__main__':
320+
main()

0 commit comments

Comments
 (0)