diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 01:59:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 01:59:52 +0300 |
| commit | 17e5dc6ee96c0028f730f661bdeaf1d888f82ffe (patch) | |
| tree | cbdefa79adeeb4ac7a52d24f48e18ce2189bc279 /scripts/changelog.py | |
| parent | c0fe95235f639adbc935192ed14b43631f617ac2 (diff) | |
feat: automate changelog-backed releases
Diffstat (limited to 'scripts/changelog.py')
| -rw-r--r-- | scripts/changelog.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/scripts/changelog.py b/scripts/changelog.py new file mode 100644 index 00000000..5a87d465 --- /dev/null +++ b/scripts/changelog.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$", re.MULTILINE) +VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") + + +@dataclass(frozen=True) +class Section: + title: str + start: int + end: int + body_start: int + + +class ChangelogError(ValueError): + pass + + +def normalize_title(raw_title: str) -> str: + title = raw_title.strip() + match = VERSION_WITH_DATE_RE.match(title) + if match: + title = match.group("version").strip() + if title.startswith("[") and title.endswith("]"): + title = title[1:-1].strip() + return title + + +def parse_sections(text: str) -> list[Section]: + matches = list(SECTION_RE.finditer(text)) + sections: list[Section] = [] + + for index, match in enumerate(matches): + title = normalize_title(match.group("bracketed") or match.group("plain") or "") + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body_start = match.end() + if body_start < len(text) and text[body_start : body_start + 1] == "\n": + body_start += 1 + sections.append(Section(title=title, start=match.start(), end=end, body_start=body_start)) + + return sections + + +def find_section(text: str, wanted_title: str) -> Section: + for section in parse_sections(text): + if section.title == wanted_title: + return section + raise ChangelogError(f"section not found: {wanted_title}") + + +def extract_section_body(text: str, wanted_title: str) -> str: + section = find_section(text, wanted_title) + body = text[section.body_start : section.end].strip("\n") + if not body.strip(): + raise ChangelogError(f"section is empty: {wanted_title}") + return body + "\n" + + +def prepare_release(text: str, version: str, release_date: str) -> str: + unreleased = None + existing_version = False + + for section in parse_sections(text): + if section.title == "Unreleased": + unreleased = section + if section.title == version: + existing_version = True + + if existing_version: + raise ChangelogError(f"version already exists in changelog: {version}") + if unreleased is None: + raise ChangelogError("missing Unreleased section") + + unreleased_body = text[unreleased.body_start : unreleased.end].strip("\n") + if not unreleased_body.strip(): + raise ChangelogError("Unreleased section is empty") + + prefix = text[: unreleased.start].rstrip("\n") + suffix = text[unreleased.end :].strip("\n") + + rebuilt = f"## Unreleased\n\n## [{version}] - {release_date}\n\n{unreleased_body}" + if suffix: + rebuilt += f"\n\n{suffix}" + + if prefix: + return f"{prefix}\n\n{rebuilt}\n" + return rebuilt + "\n" + + +def load_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ChangelogError(f"changelog not found: {path}") from exc + + +def write_text(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8") + + +def cmd_prepare(args: argparse.Namespace) -> int: + path = Path(args.path) + original = load_text(path) + updated = prepare_release(original, args.version, args.date) + write_text(path, updated) + return 0 + + +def cmd_extract(args: argparse.Namespace) -> int: + path = Path(args.path) + body = extract_section_body(load_text(path), args.version) + if args.output: + write_text(Path(args.output), body) + else: + sys.stdout.write(body) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Prepare and extract changelog release notes") + subparsers = parser.add_subparsers(dest="command", required=True) + + prepare = subparsers.add_parser("prepare", help="Move Unreleased into a versioned section") + prepare.add_argument("--path", default="CHANGELOG.md") + prepare.add_argument("--version", required=True) + prepare.add_argument("--date", default=str(date.today())) + prepare.set_defaults(func=cmd_prepare) + + extract = subparsers.add_parser("extract", help="Extract a version section body") + extract.add_argument("--path", default="CHANGELOG.md") + extract.add_argument("--version", required=True) + extract.add_argument("--output") + extract.set_defaults(func=cmd_extract) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + return args.func(args) + except ChangelogError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) |
