Files
2026-07-13 13:12:00 +08:00

416 lines
16 KiB
Python

#!/usr/bin/env python3
"""Harvest merged-PR "## Changelog" sections into the granular `CHANGELOG.md`.
Run at release time (see `.github/workflows/publish-changelog.yml`). Given a
final release tag, it:
1. finds the previous final tag (purely from git — no persisted state),
2. collects the PRs merged in that range (the `(#NNNN)` suffix on squash
commits),
3. reads each PR's `## Changelog` section via `gh`,
4. renders a Keep-a-Changelog section and inserts it into `CHANGELOG.md` in
version order (idempotent: re-running replaces the version's block).
This is the *granular* tier. The concise website post is produced separately
from the curated GitHub Release body (see `release_to_mdx.py`).
The parsing of the `## Changelog` section is shared with the PR-template gate
(`.github/scripts/pr-template/_md.py`) so the two can never disagree.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
from packaging.version import InvalidVersion, Version
# Reuse the exact section + checkbox parsing the merge gate uses.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pr-template"))
from _md import (
TYPE_TAGS,
changelog_description,
checked_labels,
section_text,
type_tag,
)
# The "Type of change" checkbox labels, in the order they appear in the template
# (mirrors validate.TYPE_LABELS). Kept here so the harvester needn't import the
# gate module; TYPE_TAGS in _md.py is the source of truth for which map to a tag.
TYPE_LABELS = tuple(TYPE_TAGS)
_FINAL_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
# A squash-merge subject ends with "(#1234)"; capture the last such reference.
_PR_REF_RE = re.compile(r"\(#(\d+)\)\s*$")
# Existing version headers in CHANGELOG.md — capture the whole bracketed tag so
# any version shape (final, rc, dev) is found, e.g. "## [v0.4.0rc1] — 2026-…".
_VERSION_HEADER_RE = re.compile(r"(?m)^##\s*\[([^\]]+)\]")
# --- version helpers ---------------------------------------------------------
#
# Two notions, deliberately distinct:
# * FINALITY (_version_tuple / previous_final_tag): only vX.Y.Z. Governs the
# default range start — a real v0.4.0 diffs against the previous *final* tag
# (v0.3.0), never an intervening v0.4.0rc1.
# * ORDERABILITY (_parse_version): any PEP 440 version, incl. dev/rc. Governs
# where a block sorts in CHANGELOG.md, so a manually-drafted dev/rc tag lands
# in the right place (and below its eventual final).
def _version_tuple(tag: str) -> tuple[int, int, int] | None:
match = _FINAL_TAG_RE.match(tag.strip())
if not match:
return None
return tuple(int(p) for p in match.groups()) # type: ignore[return-value]
def _parse_version(tag: str) -> Version | None:
"""PEP 440 version for *tag* (leading ``v`` stripped), or ``None`` if it isn't
a version at all (e.g. a branch/sha). ``Version`` sorts dev < rc < final."""
try:
return Version(tag.strip().lstrip("v"))
except InvalidVersion:
return None
def previous_final_tag(tag: str, all_tags: list[str]) -> str | None:
"""Highest *final* (vX.Y.Z) tag strictly below *tag*, or ``None`` if none.
The reference *tag* may itself be any PEP 440 version (a dev/rc tag drafted
manually still diffs against the previous final release); only the candidates
are restricted to finals.
"""
current = _parse_version(tag)
if current is None:
raise ValueError(f"{tag!r} is not a PEP 440 version")
below = [
(version, candidate)
for candidate in all_tags
if _version_tuple(candidate) is not None
and (version := _parse_version(candidate)) is not None
and version < current
]
if not below:
return None
return max(below)[1]
def pr_numbers_from_subjects(subjects: list[str]) -> list[int]:
"""PR numbers from squash-commit subjects, de-duplicated, first-seen order."""
return list(pr_titles_from_subjects(subjects))
def pr_titles_from_subjects(subjects: list[str]) -> dict[int, str]:
"""Map PR number -> title from squash-commit subjects (first seen wins).
A squash subject looks like ``feat(web): show progress bar (#1304)``; the
title is the subject with the trailing ``(#NNNN)`` reference stripped.
"""
titles: dict[int, str] = {}
for subject in subjects:
match = _PR_REF_RE.search(subject)
if not match:
continue
pr = int(match.group(1))
if pr in titles:
continue
titles[pr] = _PR_REF_RE.sub("", subject).strip()
return titles
# --- rendering ---------------------------------------------------------------
class HarvestResult:
"""Per-PR harvest outcome, for rendering and for surfacing gaps."""
def __init__(self, pr: int, title: str = "") -> None:
self.pr = pr
self.title = title
self.description = "" # first-line, free-text changelog description
self.type_tags: list[str] = [] # checked Type-of-change labels
self.status = "omitted" # included | omitted
def harvest_pr(pr: int, body: str | None, title: str = "") -> HarvestResult:
result = HarvestResult(pr, title)
if body is None:
return result
result.description = changelog_description(section_text(body, "Changelog"))
result.type_tags = sorted(checked_labels(section_text(body, "Type of change"), TYPE_LABELS))
# A PR is in the changelog iff its author wrote a description line; the tag
# comes from the Type-of-change boxes but never puts a PR in on its own.
if result.description:
result.status = "included"
return result
def _bullet(result: HarvestResult) -> str:
"""One CHANGELOG.md bullet: ``- [Tag] description (#NNNN)`` (tag optional)."""
tag = type_tag(set(result.type_tags))
prefix = f"{tag} " if tag else ""
return f"- {prefix}{result.description} (#{result.pr})"
def render_section(tag: str, date: str, results: list[HarvestResult]) -> str:
"""Render the changelog block for one version — a flat, PR-sorted list.
Each documented PR is one bullet prefixed with the bracket tag derived from
its Type-of-change checkboxes. PRs with no description are omitted entirely.
"""
included = sorted((r for r in results if r.status == "included"), key=lambda r: r.pr)
lines = [f"## [{tag}] — {date}", ""]
if included:
lines.extend(_bullet(r) for r in included)
else:
lines.append("_No user-facing changes._")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# Multi-section draft for the GitHub Release body: the Type-of-change tags collapse
# into the sections the release coordinator curates by hand (see RELEASING.md /
# the release-notes-drafter agent). This is the deterministic scaffold — the AI
# drafter refines it, and it is also the fallback when the LLM is unavailable.
# Values are "Type of change" checkbox labels (see _md.TYPE_TAGS).
DRAFT_SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Major new features", ("Feature", "UI / frontend change")),
("Breaking changes", ("Breaking change",)),
("Bug fixes", ("Bug fix",)),
)
def render_draft_notes(results: list[HarvestResult], repo: str) -> str:
"""Render the curated-draft scaffold for the GitHub Release body.
Groups documented PRs into the DRAFT_SECTIONS buckets (Major new features /
Breaking changes / Bug fixes) by their Type-of-change labels, sorted by PR
number, and appends the CHANGELOG.md link. The Bug fixes bucket is a raw
superset seeded from every "Bug fix"-tagged PR; the AI drafter curates it
down to user-facing fixes only, dropping security and CI/internal fixes
(which share the same tag). Empty sections keep their heading with a
placeholder so the coordinator sees what to fill in.
"""
included = [r for r in results if r.status == "included"]
lines: list[str] = []
for heading, labels in DRAFT_SECTIONS:
lines.append(f"## {heading}")
lines.append("")
bucket = sorted(
(r for r in included if any(label in r.type_tags for label in labels)),
key=lambda r: r.pr,
)
if bucket:
lines.extend(f"- {r.description} (#{r.pr})" for r in bucket)
else:
lines.append("<!-- no entries harvested for this section — add highlights -->")
lines.append("")
lines.append(f"Full Changelog: https://github.com/{repo}/blob/main/CHANGELOG.md")
return "\n".join(lines).rstrip() + "\n"
def render_pr_list(results: list[HarvestResult]) -> str:
"""Render the PR material fed to the release-notes-drafter agent.
One line per PR: number, title, and — when the author documented it — the
type tag and description. Titles come from the squash-commit subjects, so
even PRs that predate the `## Changelog` field give the agent something to
theme on.
"""
lines: list[str] = []
for result in sorted(results, key=lambda r: r.pr):
lines.append(f"#{result.pr}: {result.title or '(no title)'}")
if result.description:
tag = type_tag(set(result.type_tags))
prefix = f"{tag} " if tag else ""
lines.append(f" - {prefix}{result.description}")
return "\n".join(lines) + "\n"
def insert_section(changelog: str, tag: str, section: str) -> str:
"""Insert (or replace) *section* for *tag* into *changelog*, version-ordered.
Newest version first, by PEP 440 — so a final ``v0.4.0`` sorts above its own
``v0.4.0rc1`` / ``v0.4.0.dev0`` blocks, which in turn sort above ``v0.3.0``.
Re-running the same tag replaces its own block (matched by exact tag string),
making re-runs idempotent; distinct tags (final vs. its pre-releases) coexist.
"""
target = _parse_version(tag)
if target is None:
raise ValueError(f"{tag!r} is not a PEP 440 version")
headers = list(_VERSION_HEADER_RE.finditer(changelog))
blocks = [] # (header_tag, parsed_version_or_None, start, end)
for idx, match in enumerate(headers):
header_tag = match.group(1).strip()
start = match.start()
end = headers[idx + 1].start() if idx + 1 < len(headers) else len(changelog)
blocks.append((header_tag, _parse_version(header_tag), start, end))
section_block = section.rstrip() + "\n"
# Replace an existing block for this exact tag (idempotent re-run).
for header_tag, _version, start, end in blocks:
if header_tag == tag.strip():
return changelog[:start] + section_block + "\n" + changelog[end:].lstrip("\n")
# Otherwise insert before the first existing block that sorts below ours. An
# unparseable existing header is treated as oldest (sorts last).
for _header_tag, version, start, _end in blocks:
if version is None or version < target:
head = changelog[:start].rstrip("\n")
tail = changelog[start:]
return f"{head}\n\n{section_block}\n{tail}"
# No older block (we're the oldest, or the file has no version blocks yet):
# append after the preamble / existing blocks.
return changelog.rstrip("\n") + "\n\n" + section_block
# --- git / gh IO -------------------------------------------------------------
def _git(*args: str) -> str:
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout.strip()
def _all_tags() -> list[str]:
out = _git("tag", "-l", "v*")
return [line.strip() for line in out.splitlines() if line.strip()]
def _range_subjects(prev: str | None, tag: str) -> list[str]:
rng = f"{prev}..{tag}" if prev else tag
out = _git("log", "--no-merges", "--pretty=%s", rng)
return [line for line in out.splitlines() if line.strip()]
def _tag_date(tag: str) -> str:
return _git("log", "-1", "--format=%cs", tag)
def _gh_pr_body(repo: str, pr: int) -> str | None:
proc = subprocess.run(
["gh", "pr", "view", str(pr), "--repo", repo, "--json", "body", "-q", ".body"],
capture_output=True,
text=True,
)
if proc.returncode != 0:
return None
return proc.stdout
def collect(
tag: str, repo: str, base: str | None = None
) -> tuple[str, list[HarvestResult], str | None]:
"""Return (rendered_section, results, previous_tag) for *tag*.
*base* overrides the range start: when given, the harvest range is
``base..tag`` verbatim (any refs — for manual/preview runs). Otherwise the
start is the previous final ``vX.Y.Z`` tag, as at release time.
"""
prev = base or previous_final_tag(tag, _all_tags())
subjects = _range_subjects(prev, tag)
titles = pr_titles_from_subjects(subjects)
results = [harvest_pr(pr, _gh_pr_body(repo, pr), title) for pr, title in titles.items()]
section = render_section(tag, _tag_date(tag), results)
return section, results, prev
# --- CLI ---------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", required=True, help="release tag/ref (head of the range)")
parser.add_argument("--repo", required=True, help="owner/name for `gh pr view`")
parser.add_argument(
"--base",
default=None,
help="override the range start (any ref); default is the previous final "
"vX.Y.Z tag. Required when --tag is not a final vX.Y.Z (e.g. a preview run).",
)
parser.add_argument(
"--changelog-file",
default="CHANGELOG.md",
help="path to the canonical CHANGELOG.md to update in place",
)
parser.add_argument(
"--section-out",
default=None,
help="optional path to also write the rendered section on its own",
)
parser.add_argument(
"--draft-notes-out",
default=None,
help="optional path to write the curated-draft scaffold "
"(the GitHub Release body seed / LLM fallback)",
)
parser.add_argument(
"--pr-list-out",
default=None,
help="optional path to write the PR list (number/title/entries) fed to "
"the release-notes-drafter agent",
)
parser.add_argument(
"--no-changelog-update",
action="store_true",
help="skip writing CHANGELOG.md (useful when only the draft notes are wanted)",
)
args = parser.parse_args()
# CHANGELOG.md insertion orders blocks by PEP 440, so --tag must be a version
# (final, rc, or dev — all orderable). A non-version ref (branch/sha) can only
# render a preview, and needs an explicit --base for its range.
is_orderable = _parse_version(args.tag) is not None
if not is_orderable and args.base is None:
parser.error(
f"--tag {args.tag!r} is not a PEP 440 version; pass --base <ref> for its range"
)
section, results, prev = collect(args.tag, args.repo, base=args.base)
if is_orderable and not args.no_changelog_update:
path = Path(args.changelog_file)
existing = path.read_text() if path.exists() else _SEED_CHANGELOG
path.write_text(insert_section(existing, args.tag, section))
if args.section_out:
Path(args.section_out).write_text(section)
if args.draft_notes_out:
Path(args.draft_notes_out).write_text(render_draft_notes(results, args.repo))
if args.pr_list_out:
Path(args.pr_list_out).write_text(render_pr_list(results))
# Summarize what landed (non-fatal). PRs without a description line are simply
# omitted from the changelog by design — no per-PR gap warnings.
included = [r.pr for r in results if r.status == "included"]
print(f"Range: {prev or '(start)'}..{args.tag}")
print(f"Documented {len(included)} of {len(results)} PR(s) in the changelog: {included}")
print(f"Omitted (no changelog description): {len(results) - len(included)} PR(s).")
return 0
_SEED_CHANGELOG = (
"# Changelog\n\n"
"All notable user-facing changes to omnigent are documented here. This file is "
"generated at release time from each PR's `## Changelog` section, tagged by the "
"PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on "
"the website under `/releases`.\n"
)
if __name__ == "__main__":
raise SystemExit(main())