aboutsummaryrefslogtreecommitdiff
path: root/scripts/changelog.py
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 01:59:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 01:59:52 +0300
commit17e5dc6ee96c0028f730f661bdeaf1d888f82ffe (patch)
treecbdefa79adeeb4ac7a52d24f48e18ce2189bc279 /scripts/changelog.py
parentc0fe95235f639adbc935192ed14b43631f617ac2 (diff)
feat: automate changelog-backed releases
Diffstat (limited to 'scripts/changelog.py')
-rw-r--r--scripts/changelog.py158
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())