Skip to content

Commit da91e29

Browse files
authored
Merge pull request #850 from xcp-ng/gln/release-post-generator-yykx
Gln/release post generator yykx
2 parents e6ecec2 + c5a1fc0 commit da91e29

3 files changed

Lines changed: 436 additions & 12 deletions

File tree

‎scripts/pkg_in_pipe/README.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ environment variable or the `--plane-token` command line option.
1616

1717
An extra `--generated-info` command line option may be used to add some info about the report generation process.
1818

19+
The `--debug` option logs more details about the generation on stderr.
20+
1921
A machine readable version of the report can also be generated with the `--json-output` option:
2022

2123
```sh
@@ -28,6 +30,51 @@ The json report can be validated against its schema with:
2830
python -m jsonschema -i report.json pkg_in_pipe.schema.json
2931
```
3032

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

3380
Before running in docker, the docker image must be built with:

‎scripts/pkg_in_pipe/pkg_in_pipe.py‎

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import argparse
33
import io
44
import json
5+
import logging
56
import os
67
import re
8+
import sys
79
import tomllib
810
from collections import defaultdict
911
from datetime import datetime
@@ -19,6 +21,31 @@
1921
from github.GithubException import BadCredentialsException
2022
from github.PullRequest import PullRequest
2123

24+
log = logging.getLogger(__name__)
25+
26+
27+
class ColorFormatter(logging.Formatter):
28+
colors = {
29+
logging.CRITICAL: '\033[31m',
30+
logging.ERROR: '\033[31m',
31+
logging.WARNING: '\033[33m',
32+
logging.DEBUG: '\033[34m',
33+
}
34+
35+
def __init__(self, use_color: bool) -> None:
36+
super().__init__(fmt='{message}', style='{')
37+
self.use_color = use_color
38+
39+
def format(self, record: logging.LogRecord) -> str:
40+
message = super().format(record)
41+
if record.levelno == logging.INFO:
42+
return message
43+
44+
prefix = f'{record.levelname.lower()}: '
45+
if color := self.colors.get(record.levelno) if self.use_color else None:
46+
prefix = f'{color}{prefix}\033[0m'
47+
return '\n'.join(f'{prefix}{line}' for line in message.splitlines() or [''])
48+
2249

2350
with open(os.path.join(os.path.dirname(__file__), 'missing_sources.toml'), 'rb') as missing_sources_file:
2451
MISSING_SOURCES = tomllib.load(missing_sources_file)
@@ -254,6 +281,21 @@ def find_released_build(build_tag, package_name):
254281
return get_koji_build(max(tagged, key=lambda t: t['build_id'])['build_id'])
255282
return None
256283

284+
def cached(key):
285+
"""Return the value cached for that key, or None when it must be fetched again."""
286+
if args.re_cache:
287+
log.debug("cache ignored because of --re-cache: %s", key)
288+
return None
289+
if key in CACHE:
290+
log.debug("cache hit: %s", key)
291+
return CACHE[key]
292+
log.debug("cache miss: %s", key)
293+
return None
294+
295+
def cache(key, value):
296+
log.debug("caching: %s", key)
297+
CACHE.set(key, value, expire=RETENTION_TIME)
298+
257299
def find_commits(gh, repo, start_sha, end_sha) -> list[Commit]:
258300
"""
259301
List the commits in the range [start_sha,end_sha[.
@@ -262,24 +304,24 @@ def find_commits(gh, repo, start_sha, end_sha) -> list[Commit]:
262304
A commit older that the end_sha commit and added by a merge commit won't appear in this list.
263305
"""
264306
cache_key = f'commits-2-{start_sha}-{end_sha}'
265-
if not args.re_cache and cache_key in CACHE:
266-
return cast(list[Commit], CACHE[cache_key])
307+
if (commits := cast(list[Commit], cached(cache_key))) is not None:
308+
return commits
267309
commits = []
268310
if gh:
269311
for commit in gh.get_repo(repo).get_commits(start_sha):
270312
if commit.sha == end_sha:
271313
break
272314
commits.append(commit)
273-
CACHE.set(cache_key, commits, expire=RETENTION_TIME)
315+
cache(cache_key, commits)
274316
return commits
275317

276318
def find_pull_requests(repo, start_sha, end_sha):
277319
"""Find the pull requests for the commits in the [start_sha,end_sha[ range."""
278320
prs = set()
279321
for commit in find_commits(GITHUB, repo, start_sha, end_sha):
280322
cache_key = f'commit-prs-6-{commit.sha}'
281-
if not args.re_cache and cache_key in CACHE:
282-
prs.update(cast(list[PullRequest], CACHE[cache_key]))
323+
if (commit_prs := cast(list[PullRequest], cached(cache_key))) is not None:
324+
prs.update(commit_prs)
283325
elif GITHUB:
284326
commit_prs = list(commit.get_pulls())
285327
if not commit_prs:
@@ -295,17 +337,17 @@ def find_pull_requests(repo, start_sha, end_sha):
295337
# commit. The latter is needed for the PRs merged by rebase or squash, whose merge
296338
# commit on the base branch is not one of the PR commits.
297339
commit_prs = [pr for pr in commit_prs if commit in pr.get_commits() or commit.sha == pr.merge_commit_sha]
298-
CACHE.set(cache_key, commit_prs, expire=RETENTION_TIME)
340+
cache(cache_key, commit_prs)
299341
prs.update(commit_prs)
300342
return sorted(prs, key=lambda p: p.number, reverse=True)
301343

302344
def get_koji_build(build_id) -> dict:
303345
cache_key = f'koji-build-{build_id}'
304-
if not args.re_cache and cache_key in CACHE:
305-
return cast(dict, CACHE[cache_key])
346+
if (build := cast(dict, cached(cache_key))) is not None:
347+
return build
306348
else:
307349
build = KOJI.getBuild(build_id)
308-
CACHE.set(cache_key, build, expire=RETENTION_TIME)
350+
cache(cache_key, build)
309351
return build
310352

311353
def get_plane_issues(plane_token):
@@ -409,8 +451,15 @@ def get_plane_issues_with_milestones(plane_token):
409451
)
410452
parser.add_argument('--re-cache', help="Refresh the cache", action='store_true')
411453
parser.add_argument('--json-output', help="Also write a machine readable report in json format to this path")
454+
parser.add_argument('--debug', help="Log more details on stderr", action='store_true')
412455
args = parser.parse_args()
413456

457+
handler = logging.StreamHandler()
458+
handler.setFormatter(ColorFormatter(
459+
not os.environ.get('NO_COLOR') and (bool(os.environ.get('FORCE_COLOR')) or sys.stderr.isatty()),
460+
))
461+
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO, handlers=[handler])
462+
414463
CACHE = diskcache.Cache(args.cache)
415464
RETENTION_TIME = 24 * 60 * 60 # 24 hours
416465

@@ -426,9 +475,9 @@ def get_plane_issues_with_milestones(plane_token):
426475
# load the issues from plane, so we can search for the plane card related to a build
427476
try:
428477
issues = get_plane_issues_with_milestones(args.plane_token)
429-
except Exception:
478+
except Exception as e:
430479
issues = []
431-
raise
480+
log.warning("failed to load the tickets from plane: %s", e)
432481

433482
# connect to github
434483
GITHUB = None
@@ -438,6 +487,12 @@ def get_plane_issues_with_milestones(plane_token):
438487
GITHUB.get_repo('xcp-ng/xcp') # check that the token is valid
439488
except BadCredentialsException:
440489
GITHUB = None
490+
log.warning("the github token is invalid")
491+
except github.GithubException as e:
492+
GITHUB = None
493+
log.warning("failed to connect to github: %s", e)
494+
else:
495+
log.warning("no github token, the pull requests come from the cache")
441496

442497
# load the packages maintainers
443498
with urlopen('https://github.com/xcp-ng/xcp/raw/refs/heads/master/scripts/rpm_owners/packages.json') as f:
@@ -464,6 +519,7 @@ def get_plane_issues_with_milestones(plane_token):
464519
KOJI = koji.ClientSession('https://kojihub.xcp-ng.org', config)
465520
KOJI.ssl_login(config['cert'], None, config['serverca'])
466521
for tag in tags:
522+
log.info('processing tag %s', tag)
467523
tag_data = {'tag': tag, 'builds': []}
468524
report_data['tags'].append(tag_data)
469525
tag_history = dict(
@@ -475,6 +531,8 @@ def get_plane_issues_with_milestones(plane_token):
475531
taggeds = (t for t in taggeds if t['package_name'] in args.packages or args.packages == [])
476532
taggeds = sorted(taggeds, key=lambda t: (tag_history[t['build_id']], t['build_id']), reverse=True)
477533
for tagged in taggeds:
534+
build_url = f'https://koji.xcp-ng.org/buildinfo?buildID={tagged["build_id"]}'
535+
log.info(' processing build %s (%s)', tagged['nvr'], build_url)
478536
build = get_koji_build(tagged['build_id'])
479537
prs: list[PullRequest] = []
480538
maintained_by = None
@@ -484,7 +542,6 @@ def get_plane_issues_with_milestones(plane_token):
484542
(repo, sha) = parse_source(build['source'])
485543
prs = find_pull_requests(repo, sha, previous_build_sha)
486544
maintained_by = PACKAGES.get(tagged['package_name'], {}).get('maintainer')
487-
build_url = f'https://koji.xcp-ng.org/buildinfo?buildID={tagged["build_id"]}'
488545
build_issues = filter_issues(issues, [build_url] + [pr.html_url for pr in prs])
489546
print_table_line(
490547
temp_out, tagged['nvr'], build_url, build_issues, tagged['owner_name'], prs, maintained_by

0 commit comments

Comments
 (0)