Files
tw93--kami/scripts/draft-release-notes.py
2026-07-13 13:05:52 +08:00

181 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Draft the next Kami release notes from git log.
Pulls commit subjects in a rev range and pours them into the V1.4.0-style
template (centered logo + bilingual changelog). The output is a starting
point: regroup the commits into 5-8 product-themed bullets and translate
each to Chinese before publishing.
Usage:
python3 scripts/draft-release-notes.py
python3 scripts/draft-release-notes.py V1.4.0..HEAD
python3 scripts/draft-release-notes.py \\
--version V1.5.0 \\
--title "Steadier Hand" \\
--subtitle-en "Plugin install fix and audit cleanup." \\
--subtitle-cn "插件安装修复,审计清理沉淀。"
The default rev range is `<latest tag>..HEAD`. Output goes to stdout; pipe
to a file or `pbcopy` to feed `gh release create --notes-file`.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from textwrap import dedent
_HEADER = dedent("""\
<div align="center">
<img src="https://gw.alipayobjects.com/zos/k/vl/logo.svg" alt="Kami Logo" width="120" />
<h1 style="margin: 12px 0 6px;">Kami {version}</h1>
<p><em>{subtitle_en}</em></p>
</div>
""")
_FOOTER = dedent("""\
> Kami is a quiet design system for professional documents, one constraint set that any agent can trust. https://github.com/tw93/Kami
""")
# Conventional-commit prefix to a short product label, used as a hint when
# the user reorganizes the auto-listed commits into themed bullets.
_PREFIX_HINT = {
"build": "build",
"ci": "ci",
"feat": "feature",
"fix": "fix",
"docs": "docs",
"chore": "chore",
"refactor": "refactor",
"test": "test",
"perf": "perf",
"revert": "revert",
}
def _run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"{' '.join(cmd)}: {result.stderr.strip() or 'failed'}")
return result.stdout
def latest_tag() -> str | None:
try:
out = _run(["git", "describe", "--tags", "--abbrev=0"]).strip()
except RuntimeError:
return None
return out or None
def commits_in(rev_range: str) -> list[tuple[str, str]]:
"""Return [(short_sha, subject), ...] in chronological order."""
out = _run(["git", "log", rev_range, "--format=%h\t%s", "--reverse"])
rows: list[tuple[str, str]] = []
for line in out.splitlines():
if "\t" in line:
sha, subject = line.split("\t", 1)
rows.append((sha.strip(), subject.strip()))
return rows
def classify(subject: str) -> str:
"""Return a one-word commit category derived from the conventional-commit prefix."""
head = subject.split(":", 1)[0].split("(", 1)[0].strip().lower()
return _PREFIX_HINT.get(head, "other")
def render(
version: str,
title: str,
subtitle_en: str,
subtitle_cn: str,
rev_range: str,
commits: list[tuple[str, str]],
) -> str:
out: list[str] = []
out.append(_HEADER.format(version=version, subtitle_en=subtitle_en))
out.append(f"<!-- title: {version} {title} -->")
out.append(f"<!-- range: {rev_range} ({len(commits)} commits) -->")
out.append("<!-- regroup the bullets below into 5-8 product-themed items -->")
out.append("")
out.append("### Changelog")
out.append("")
for i, (sha, subject) in enumerate(commits, 1):
out.append(f"{i}. **TODO**: {subject} <!-- {sha} {classify(subject)} -->")
out.append("")
out.append("### 更新日志")
out.append("")
out.append(f"<!-- 副标题: {subtitle_cn} -->")
out.append("<!-- 翻译并对齐到上面英文条目,保持一一对应 -->")
out.append("")
for i in range(1, len(commits) + 1):
out.append(f"{i}. **TODO**:(对应英文第 {i} 条)")
out.append("")
out.append(_FOOTER)
return "\n".join(out)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"rev_range",
nargs="?",
default=None,
help="Git rev range (default: <latest tag>..HEAD)",
)
parser.add_argument("--version", default="VX.Y.Z", help="Version label, e.g. V1.5.0")
parser.add_argument("--title", default="<title>", help="Release title, e.g. 'Steadier Hand'")
parser.add_argument(
"--subtitle-en",
default="<one-line subtitle>",
help="Short English subtitle for the centered hero block",
)
parser.add_argument(
"--subtitle-cn",
default="<一句话中文副标题>",
help="Short Chinese subtitle, kept as a comment hint for translators",
)
return parser.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = parse_args(argv)
rev_range = args.rev_range
if rev_range is None:
tag = latest_tag()
if not tag:
print("ERROR: no tag found; pass an explicit rev range like V1.0.0..HEAD",
file=sys.stderr)
return 2
rev_range = f"{tag}..HEAD"
try:
commits = commits_in(rev_range)
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if not commits:
print(f"ERROR: no commits in {rev_range}", file=sys.stderr)
return 1
out = render(
version=args.version,
title=args.title,
subtitle_en=args.subtitle_en,
subtitle_cn=args.subtitle_cn,
rev_range=rev_range,
commits=commits,
)
print(out)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))