Files
lix/maintainers/build-release-notes.py
rootile c019a776e9 python-infra: move f2 specific stuff into f2
When extracting a global pyproject.toml from the f2 one, some f2
specific things slipped through and ended up in the global one.
This commit moves those parts back where they belong

Change-Id: I95706fabec487410717fb362c95bfee900cf5b8c
2026-06-14 20:56:18 +02:00

210 lines
6.3 KiB
Python
Executable File

#!@python@
# This is the only file and is executable
from collections import defaultdict
import frontmatter
import pathlib
import textwrap
from typing import Any
import dataclasses
import yaml
import argparse
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.
#
# Please update doc/manual/src/contributing/hacking.md if you do. Thanks~
CATEGORIES = [
"Breaking Changes",
"Features",
"Improvements",
"Fixes",
"Packaging",
"Development",
"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})"
if self.github:
return f"[{self.show_name()}]({GH_ROOT}{self.github})"
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) -> str:
if name in self.author_info:
return str(self.author_info[name])
if self.throw_on_missing:
msg = f"Missing author info for author {name}"
raise Exception(msg)
return name
def format_link(ident: str, gh_part: str, fj_part: str) -> str:
if ident.isdigit():
ident = f"lix#{ident}"
# Unify the strings, in order for them to be consistent throughout the release notes.
ident = ident.replace("gh#", "nix#").replace("fj#", "lix#")
if ident.startswith("nix#"):
num, link, base = int(ident[4:]), ident, f"{GH_REPO_BASE}/{gh_part}"
elif ident.startswith("lix#"):
num, link, base = int(ident[4:]), ident, f"{FORGEJO_REPO_BASE}/{fj_part}"
else:
msg = f"unrecognized reference format: {ident}"
raise ValueError(msg)
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)
comma = "," if len(strs) >= 3 else ""
return "{}{} and {}".format(", ".join(strs[:-1]), comma, strs[-1])
def listify(li: list | int) -> list:
if not isinstance(li, list):
return [li]
return li
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(int(cl)) for cl in listify(entry.metadata.get("cls", []))]
if links != []:
header += " " + " ".join(links)
if header:
print(f"- {header}")
print()
else:
print("- ", end="")
print(textwrap.indent(entry.content, " "))
if credits_authors := listify(entry.metadata.get("credits", [])):
print()
print(
textwrap.indent(
f"Many thanks to {plural_list([author_info[c] for c in credits_authors])} for this.",
" ",
)
)
# Blank line after each entry.
print()
except Exception as e:
e.add_note(f"in {p}")
raise
def run_on_dir(author_info: AuthorInfoDB, d: str):
d = pathlib.Path(d)
if not d.is_dir():
msg = f"provided path {d} is not a directory"
raise ValueError(msg)
paths = d.glob("[!.]*.md")
entries = defaultdict(list)
for p in paths:
try:
e = frontmatter.load(p) # type: ignore
if "synopsis" not in e.metadata:
msg = "missing synopsis"
raise ValueError(msg)
unknown_keys = set(e.metadata.keys()) - set(KNOWN_KEYS)
if unknown_keys:
msg = f"unknown keys: {unknown_keys}"
raise ValueError(msg)
category = e.metadata.get("category", "Miscellany")
if category not in CATEGORIES:
msg = f"unknown category: {category}"
raise ValueError(msg)
entries[category].append((p, e))
except Exception as e:
e.add_note(f"in {p}")
raise
for category in CATEGORIES:
if entries[category]:
print("##", category)
# Blank line after each heading.
print()
do_category(author_info, entries[category])
# Blank line after each category.
# This turns into two blank lines when combined with the blank line
# after each entry.
print()
def main():
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()