codeflash-agent/scripts/combine-changelogs.py

127 lines
3.8 KiB
Python
Raw Permalink Normal View History

2026-04-09 08:36:01 +00:00
"""Combine per-branch changelog entries into a package CHANGELOG.md.
Usage:
python scripts/combine-changelogs.py PACKAGE
Reads all .md files from packages/<PACKAGE>/changelogs/, combines them by
subsection (Enhancements / Features / Fixes), prepends the result to
packages/<PACKAGE>/CHANGELOG.md, and deletes the source entries.
"""
from __future__ import annotations
import os
import sys
import warnings
from collections import OrderedDict
from pathlib import Path
import tomlkit
REPO_ROOT = Path(__file__).resolve().parent.parent
PACKAGES_DIR = REPO_ROOT / "packages"
SUBSECTION_TYPES = ["### Enhancements", "### Features", "### Fixes"]
def ensure_changelog_folder_purity(folder: Path) -> None:
"""Raise if the changelogs folder contains non-.md files."""
if not folder.exists():
return
for name in os.listdir(folder):
if not name.endswith(".md"):
msg = (
f"Found non-markdown file {name!r} in {folder}. "
"Changelogs must be .md files."
)
raise ValueError(msg)
def parse_subsections(path: Path) -> dict[str, list[str]]:
"""Extract subsection -> lines mapping from a changelog entry."""
subsections: dict[str, list[str]] = {}
current: str | None = None
for line in path.read_text().splitlines():
if any(st in line for st in SUBSECTION_TYPES):
current = line.strip()
subsections[current] = []
elif line.strip() and current is not None:
subsections[current].append(line.strip().lstrip("-").lstrip(" "))
return subsections
def combine_files(folder: Path) -> dict[str, list[str]]:
"""Combine subsections from all changelog entries in a folder."""
ensure_changelog_folder_purity(folder)
combined: dict[str, list[str]] = {}
if not folder.exists():
return combined
for name in sorted(os.listdir(folder)):
if not name.endswith(".md"):
warnings.warn(
f"Ignoring non-markdown file {name!r} in {folder}.",
stacklevel=2,
)
continue
for section, lines in parse_subsections(folder / name).items():
combined.setdefault(section, []).extend(lines)
return combined
def serialize(combined: dict[str, list[str]], version: str) -> str:
"""Format combined subsections as a markdown release block."""
out = f"## {version}"
for section, lines in combined.items():
out += f"\n\n{section}"
for line in lines:
out += f"\n- {line}"
out += "\n\n"
return out
def main() -> None:
"""Combine changelogs for a package and update CHANGELOG.md."""
if len(sys.argv) < 2:
print("Usage: python scripts/combine-changelogs.py PACKAGE")
sys.exit(1)
package = sys.argv[1]
pkg_dir = PACKAGES_DIR / package
changelogs_dir = pkg_dir / "changelogs"
changelog_md = pkg_dir / "CHANGELOG.md"
pyproject = pkg_dir / "pyproject.toml"
if not pyproject.exists():
print(f"No pyproject.toml at {pyproject}")
sys.exit(1)
data = tomlkit.parse(pyproject.read_text())
version = str(data["project"]["version"])
combined = OrderedDict(sorted(combine_files(changelogs_dir).items()))
if not any(combined.values()):
print(f"{package}: no changelog entries to combine.")
return
updates = serialize(combined, version)
existing = changelog_md.read_text() if changelog_md.exists() else ""
changelog_md.write_text(updates + existing)
print(f"{package}: updated {changelog_md.relative_to(REPO_ROOT)}")
# Clean up individual entries.
if changelogs_dir.exists():
for entry in changelogs_dir.iterdir():
entry.unlink()
print(f"{package}: cleaned up {changelogs_dir.relative_to(REPO_ROOT)}/")
if __name__ == "__main__":
main()