lix-releng-staging/maintainers/build-release-notes.py
jade c8329e5667 build-release-notes: add change author info DB
This allows us to have links to peoples' GitHub and Forgejo profiles.

I used YAML because I don't want to introduce a dependency on having a
working Nix evaluator to be able to build release notes, and we already
have a YAML parser in this script.

Change-Id: Idf2813f79e0407460c796cba6c383496465e152d
2024-05-15 14:33:35 -07:00

168 lines
5.5 KiB
Python

from collections import defaultdict
import frontmatter
import sys
import pathlib
import textwrap
from typing import Any, Tuple
import dataclasses
import yaml
GH_ROOT = "https://github.com/"
GH_REPO_BASE = "https://github.com/NixOS/nix"
FORGEJO_REPO_BASE = "https://git.lix.systems/lix-project/lix"
FORGEJO_ROOT = "https://git.lix.systems/"
GERRIT_BASE = "https://gerrit.lix.systems/c/lix/+"
KNOWN_KEYS = ('synopsis', 'cls', 'issues', 'prs', 'significance', 'category', 'credits')
SIGNIFICANCECES = {
None: 0,
'significant': 10,
}
# This is just hardcoded for better validation. If you think there should be
# more of them, feel free to add more.
CATEGORIES = [
'Breaking Changes',
'Features',
'Improvements',
'Fixes',
'Packaging',
'Miscellany',
]
@dataclasses.dataclass
class AuthorInfo:
name: str
github: str | None = None
forgejo: str | None = None
display_name: str | None = None
def show_name(self) -> str:
return self.display_name or self.name
def __str__(self) -> str:
if self.forgejo:
return f'[{self.show_name()}]({FORGEJO_ROOT}{self.forgejo})'
elif self.github:
return f'[{self.show_name()}]({GH_ROOT}{self.github})'
else:
return self.show_name()
class AuthorInfoDB:
def __init__(self, author_info: dict[str, dict], throw_on_missing: bool):
self.author_info = {name: AuthorInfo(name=name, **d) for (name, d) in author_info.items()}
self.throw_on_missing = throw_on_missing
def __getitem__(self, name) -> str:
if name in self.author_info:
return str(self.author_info[name])
else:
if self.throw_on_missing:
raise Exception(f'Missing author info for author {name}')
else:
return name
def format_link(ident: str, gh_part: str, fj_part: str) -> str:
# FIXME: deprecate github as default
if ident.isdigit():
num, link, base = int(ident), f"#{ident}", f"{GH_REPO_BASE}/{gh_part}"
elif ident.startswith("gh#"):
num, link, base = int(ident[3:]), ident, f"{GH_REPO_BASE}/{gh_part}"
elif ident.startswith("fj#"):
num, link, base = int(ident[3:]), ident, f"{FORGEJO_REPO_BASE}/{fj_part}"
else:
raise Exception("unrecognized reference format", ident)
return f"[{link}]({base}/{num})"
def format_issue(issue: str) -> str:
return format_link(issue, "issues", "issues")
def format_pr(pr: str) -> str:
return format_link(pr, "pull", "pulls")
def format_cl(clid: int) -> str:
return f"[cl/{clid}]({GERRIT_BASE}/{clid})"
def plural_list(strs: list[str]) -> str:
if len(strs) <= 1:
return ''.join(strs)
else:
comma = ',' if len(strs) >= 3 else ''
return '{}{} and {}'.format(', '.join(strs[:-1]), comma, strs[-1])
def listify(l: list | int) -> list:
if not isinstance(l, list):
return [l]
else:
return l
def do_category(author_info: AuthorInfoDB, entries: list[Tuple[pathlib.Path, Any]]):
for p, entry in sorted(entries, key=lambda e: (-SIGNIFICANCECES[e[1].metadata.get('significance')], e[0])):
try:
header = entry.metadata['synopsis']
links = []
links += [format_issue(str(s)) for s in listify(entry.metadata.get('issues', []))]
links += [format_pr(str(s)) for s in listify(entry.metadata.get('prs', []))]
links += [format_cl(cl) for cl in listify(entry.metadata.get('cls', []))]
if links != []:
header += " " + " ".join(links)
if header:
print(f"- {header}")
print()
print(textwrap.indent(entry.content, ' '))
if credits := listify(entry.metadata.get('credits', [])):
print()
print(textwrap.indent('Many thanks to {} for this.'.format(plural_list(list(author_info[c] for c in credits))), ' '))
except Exception as e:
e.add_note(f"in {p}")
raise
def run_on_dir(author_info: AuthorInfoDB, d):
d = pathlib.Path(d)
if not d.is_dir():
raise ValueError(f'provided path {d} is not a directory')
paths = pathlib.Path(d).glob('*.md')
entries = defaultdict(list)
for p in paths:
try:
e = frontmatter.load(p)
if 'synopsis' not in e.metadata:
raise Exception('missing synopsis')
unknownKeys = set(e.metadata.keys()) - set(KNOWN_KEYS)
if unknownKeys:
raise Exception('unknown keys', unknownKeys)
category = e.metadata.get('category', 'Miscellany')
if category not in CATEGORIES:
raise Exception('unknown category', category)
entries[category].append((p, e))
except Exception as e:
e.add_note(f"in {p}")
raise
for category in CATEGORIES:
if entries[category]:
print('\n#', category)
do_category(author_info, entries[category])
def main():
import argparse
ap = argparse.ArgumentParser()
ap.add_argument('--change-authors', help='File name of the change authors metadata YAML file', type=argparse.FileType('r'))
ap.add_argument('dirs', help='Directories to run on', nargs='+')
args = ap.parse_args()
author_info = AuthorInfoDB(yaml.safe_load(args.change_authors), throw_on_missing=True) \
if args.change_authors \
else AuthorInfoDB({}, throw_on_missing=False)
for d in args.dirs:
run_on_dir(author_info, d)
if __name__ == '__main__':
main()