#!/usr/bin/env python3 """ Tiny AI-changelog-writer MVP for BTNOMB idea_014. Reads git commits between two refs and writes grouped release notes in Markdown. No network calls, no tokens, no repo credentials. """ from __future__ import annotations import argparse import re import subprocess from dataclasses import dataclass from pathlib import Path GROUPS = [ ("breaking", "Breaking Changes", ("breaking", "!:")), ("features", "Features", ("feat", "feature", "add")), ("fixes", "Fixes", ("fix", "bugfix", "hotfix")), ("docs", "Docs", ("docs", "doc")), ("maintenance", "Maintenance", ("chore", "refactor", "perf", "test", "ci", "build")), ] @dataclass class Commit: sha: str subject: str body: str def run_git(args: list[str], repo: Path) -> str: result = subprocess.run( ["git", *args], cwd=repo, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: raise SystemExit(result.stderr.strip() or "git command failed") return result.stdout def parse_commits(raw: str) -> list[Commit]: commits: list[Commit] = [] for block in raw.split("\x1e"): block = block.strip() if not block: continue parts = block.split("\x1f") if len(parts) >= 3: commits.append(Commit(parts[0].strip(), parts[1].strip(), parts[2].strip())) return commits def classify(commit: Commit) -> str: text = f"{commit.subject}\n{commit.body}".lower() conventional = re.match(r"^([a-z]+)(\(.+\))?(!)?:", commit.subject.lower()) prefix = conventional.group(1) if conventional else "" bang = bool(conventional and conventional.group(3)) if bang or "breaking change" in text: return "breaking" for key, _title, hints in GROUPS: if prefix in hints or any(text.startswith(f"{hint}:") for hint in hints): return key return "maintenance" def clean_subject(subject: str) -> str: subject = re.sub(r"^[a-z]+(\(.+\))?!?:\s*", "", subject, flags=re.I) return subject[:1].upper() + subject[1:] def render_release_notes(version: str, commits: list[Commit]) -> str: buckets = {key: [] for key, _title, _hints in GROUPS} for commit in commits: buckets[classify(commit)].append(commit) lines = [f"# Release Notes: {version}", ""] if not commits: lines += ["No commits found in the selected range.", ""] return "\n".join(lines) lines += [ "## Summary", "", f"This release includes {len(commits)} commit(s), grouped by user-facing impact.", "", ] for key, title, _hints in GROUPS: items = buckets[key] if not items: continue lines += [f"## {title}", ""] for commit in items: lines.append(f"- {clean_subject(commit.subject)} (`{commit.sha[:7]}`)") lines.append("") lines += [ "## Review Checklist", "", "- Confirm breaking-change notes are accurate.", "- Add customer-facing context where commit messages are too terse.", "- Link PRs/issues before publishing to GitHub Releases.", "", ] return "\n".join(lines) def main() -> None: parser = argparse.ArgumentParser(description="Generate Markdown release notes from git history.") parser.add_argument("--repo", default=".", help="Path to a git repository.") parser.add_argument("--from-ref", default="", help="Older ref/tag. If omitted, uses the previous tag.") parser.add_argument("--to-ref", default="HEAD", help="Newer ref/tag. Defaults to HEAD.") parser.add_argument("--version", default="", help="Release name shown in the Markdown title.") parser.add_argument("--output", default="", help="Optional file path for generated Markdown.") parser.add_argument( "--log-file", default="", help="Optional test input with one commit per line: shasubjectbody.", ) args = parser.parse_args() repo = Path(args.repo).resolve() version = args.version or args.to_ref if args.log_file: rows = Path(args.log_file).read_text(encoding="utf-8").splitlines() commits = [] for row in rows: parts = row.split("\t") if len(parts) >= 2: commits.append(Commit(parts[0], parts[1], parts[2] if len(parts) > 2 else "")) else: from_ref = args.from_ref if not from_ref: from_ref = run_git(["describe", "--tags", "--abbrev=0", f"{args.to_ref}^"], repo).strip() log_format = "%H%x1f%s%x1f%b%x1e" raw = run_git(["log", "--no-merges", f"--pretty=format:{log_format}", f"{from_ref}..{args.to_ref}"], repo) commits = parse_commits(raw) notes = render_release_notes(version, commits) if args.output: Path(args.output).write_text(notes, encoding="utf-8") else: print(notes) if __name__ == "__main__": main()