#!/usr/bin/env python3 """Generate a deterministic Keep-a-Changelog draft from a Git repository.""" from __future__ import annotations import argparse import re import subprocess from collections import defaultdict from pathlib import Path GROUPS = { 'feat': 'Added', 'fix': 'Fixed', 'perf': 'Changed', 'refactor': 'Changed', 'docs': 'Documentation', 'test': 'Testing', 'build': 'Build', 'ci': 'CI', 'chore': 'Maintenance', 'revert': 'Reverted', } COMMIT = re.compile(r'^(?P[a-zA-Z]+)(?:\((?P[^)]+)\))?(?P!)?:\s*(?P.+)$') def parse(subject: str) -> tuple[str, str]: match = COMMIT.match(subject.strip()) if not match: return 'Other', subject.strip() category = GROUPS.get(match.group('type').lower(), 'Other') scope = match.group('scope') text = match.group('text').strip() if scope: text = f'**{scope}:** {text}' if match.group('breaking'): text = f'**BREAKING:** {text}' return category, text def render(subjects: list[str], title: str = 'Unreleased') -> str: grouped: dict[str, list[str]] = defaultdict(list) seen: set[tuple[str, str]] = set() for subject in subjects: category, text = parse(subject) key = (category, text.casefold()) if text and key not in seen: seen.add(key) grouped[category].append(text) order = list(dict.fromkeys(GROUPS.values())) + ['Other'] lines = [f'## [{title}]', ''] for category in order: entries = grouped.get(category, []) if not entries: continue lines.extend([f'### {category}', '']) lines.extend(f'- {entry}' for entry in entries) lines.append('') if len(lines) == 2: lines.extend(['_No changes found._', '']) return '\n'.join(lines).rstrip() + '\n' def git_subjects(repo: Path, since: str | None, until: str) -> list[str]: revision = f'{since}..{until}' if since else until proc = subprocess.run( ['git', '-C', str(repo), 'log', '--no-merges', '--format=%s', revision], check=True, text=True, capture_output=True, ) return [line for line in proc.stdout.splitlines() if line.strip()] def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--repo', type=Path, default=Path('.')) parser.add_argument('--since', help='Start tag or commit, excluded') parser.add_argument('--until', default='HEAD', help='End revision, included') parser.add_argument('--title', default='Unreleased') parser.add_argument('--output', type=Path) args = parser.parse_args() output = render(git_subjects(args.repo, args.since, args.until), args.title) if args.output: args.output.write_text(output, encoding='utf-8') else: print(output, end='') if __name__ == '__main__': main()