diff --git a/headroom/release_version.py b/headroom/release_version.py index eee5b9947..740ad3965 100644 --- a/headroom/release_version.py +++ b/headroom/release_version.py @@ -95,9 +95,10 @@ def compute_release_version( """Compute the next release version from the canonical version and existing tags.""" if manual_version: + manual = str(SemVer.parse(manual_version)) return ReleaseVersionInfo( - version=manual_version, - npm_version=manual_version, + version=manual, + npm_version=manual, canonical=canonical_version, height="0", bump="manual", diff --git a/scripts/changelog-gen.py b/scripts/changelog-gen.py index 7d2ebbb10..761591643 100644 --- a/scripts/changelog-gen.py +++ b/scripts/changelog-gen.py @@ -1,141 +1,203 @@ -#!/usr/bin/env python3 -"""Generate changelog from conventional commits.""" - -from __future__ import annotations - -import argparse -import re -import subprocess -from datetime import date -from pathlib import Path -from typing import NamedTuple - -ROOT = Path(__file__).parent.parent - -COMMIT_PATTERN = re.compile( - r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$" -) -BREAKING_CHANGE_PATTERN = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE) -# Pattern to match each commit entry: subject, optional body, |, hash -# %s%n%b|%H format: "subject\nbody|hash" or "subject|hash" if no body -COMMIT_ENTRY_PATTERN = re.compile(r"^(.+?)(?:\n(.+))?\|(\w+)$", re.MULTILINE) - -TYPE_LABELS: dict[str, str] = { - "feat": "Features", - "fix": "Bug Fixes", - "ci": "CI/CD", - "chore": "Chores", - "perf": "Performance", - "refactor": "Refactors", - "docs": "Documentation", - "style": "Styles", - "test": "Tests", -} - - -class ParsedCommit(NamedTuple): - type: str - scope: str | None - breaking: bool - message: str - hash: str - - -def parse_commits(log_output: str) -> list[ParsedCommit]: - """Parse git log output into structured commits.""" - commits: list[ParsedCommit] = [] - - for match in COMMIT_ENTRY_PATTERN.finditer(log_output): - subject = match.group(1) - body = match.group(2) or "" - commit_hash = match.group(3) - - is_breaking = bool(BREAKING_CHANGE_PATTERN.search(body)) - commit_match = COMMIT_PATTERN.match(subject) - if commit_match: - commit_type = commit_match.group(1) - scope = commit_match.group(2) - if scope: - scope = scope[1:-1] # Remove parentheses - is_breaking = is_breaking or bool(commit_match.group(3)) # ! in subject - message = commit_match.group(4) - commits.append( - ParsedCommit( - type=commit_type, - scope=scope, - breaking=is_breaking, - message=message, - hash=commit_hash, - ) - ) - return commits - - -def generate_changelog(version: str, commits: list[ParsedCommit]) -> str: - """Generate markdown changelog from parsed commits.""" - today = date.today().isoformat() - lines = [f"## [{version}] - {today}", ""] - - # Collect breaking changes - breaking_commits = [c for c in commits if c.breaking] - if breaking_commits: - lines.append("### Breaking Changes") - for commit in breaking_commits: - if commit.scope: - lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})") - else: - lines.append(f"- {commit.message} ({commit.hash})") - lines.append("") - - # Group by type - by_type: dict[str, list[ParsedCommit]] = {} - for commit in commits: - by_type.setdefault(commit.type, []).append(commit) - - for commit_type, label in TYPE_LABELS.items(): - type_commits = by_type.get(commit_type, []) - if not type_commits: - continue - lines.append(f"### {label}") - for commit in type_commits: - if commit.scope: - lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})") - else: - lines.append(f"- {commit.message} ({commit.hash})") - lines.append("") - - return "\n".join(lines) + "\n" - - -def run_git_log(since: str | None, cwd: Path) -> str: - """Run git log command and return output.""" - cmd = ["git", "log", "--pretty=format:%s%n%b|%H"] - if since: - cmd.append(f"{since}..HEAD") - else: - cmd.append("HEAD") - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - return result.stdout - - -def main() -> None: - parser = argparse.ArgumentParser(description="Generate changelog from conventional commits") - parser.add_argument("--version", required=True, help="Version number (e.g., 0.6.0)") - parser.add_argument("--since", help="Starting tag (exclusive)") - parser.add_argument("--dry-run", action="store_true", help="Print to stdout instead of writing") - args = parser.parse_args() - - log_output = run_git_log(args.since, ROOT) - commits = parse_commits(log_output) - changelog = generate_changelog(args.version, commits) - - if args.dry_run: - print(changelog) - else: - output_path = ROOT / ".changelog.md" - output_path.write_text(changelog, encoding="utf-8") - print(f"Changelog written to {output_path}") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""Generate changelog from conventional commits.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +from datetime import date +from pathlib import Path +from typing import NamedTuple + +ROOT = Path(__file__).parent.parent + +COMMIT_PATTERN = re.compile( + r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$" +) +BREAKING_CHANGE_PATTERN = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE) +COMMIT_ENTRY_PATTERN = re.compile(r"^(.+?)(?:\n(.+))?\|(\w+)$", re.MULTILINE) +FIELD_SEP = "\x1f" +RECORD_SEP = "\x1e" +GIT_LOG_FORMAT = "%s%x1f%b%x1f%h%x1e" + +TYPE_LABELS: dict[str, str] = { + "feat": "Features", + "fix": "Bug Fixes", + "ci": "CI/CD", + "chore": "Chores", + "perf": "Performance", + "refactor": "Refactors", + "docs": "Documentation", + "style": "Styles", + "test": "Tests", + "other": "Other Changes", +} + + +class ParsedCommit(NamedTuple): + type: str + scope: str | None + breaking: bool + message: str + hash: str + + +def iter_commit_entries(log_output: str) -> list[tuple[str, str, str]]: + """Split raw git log output into (subject, body, hash) tuples.""" + + if not log_output.strip(): + return [] + + if RECORD_SEP in log_output and FIELD_SEP in log_output: + entries: list[tuple[str, str, str]] = [] + for raw_entry in log_output.split(RECORD_SEP): + if not raw_entry: + continue + if FIELD_SEP not in raw_entry: + continue + subject, body_and_hash = raw_entry.split(FIELD_SEP, 1) + if FIELD_SEP not in body_and_hash: + continue + body, commit_hash = body_and_hash.rsplit(FIELD_SEP, 1) + entries.append((subject.strip(), body.strip(), commit_hash.strip())) + return entries + + return [ + ( + match.group(1).strip(), + (match.group(2) or "").strip(), + match.group(3).strip(), + ) + for match in COMMIT_ENTRY_PATTERN.finditer(log_output) + ] + + +def get_merge_summary(subject: str, body: str) -> str: + """Return the first meaningful summary line for a merge commit.""" + + if not subject.startswith("Merge "): + return "" + + for line in body.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def parse_commits(log_output: str) -> list[ParsedCommit]: + """Parse git log output into structured commits.""" + + commits: list[ParsedCommit] = [] + + for subject, body, commit_hash in iter_commit_entries(log_output): + is_breaking = bool(BREAKING_CHANGE_PATTERN.search(body)) + merge_summary = get_merge_summary(subject, body) + candidates = [subject] + if merge_summary: + candidates.insert(0, merge_summary) + + for candidate in candidates: + commit_match = COMMIT_PATTERN.match(candidate) + if not commit_match: + continue + + scope = commit_match.group(2) + if scope: + scope = scope[1:-1] + commits.append( + ParsedCommit( + type=commit_match.group(1), + scope=scope, + breaking=is_breaking or bool(commit_match.group(3)), + message=commit_match.group(4), + hash=commit_hash, + ) + ) + break + else: + fallback_message = merge_summary or subject + if not fallback_message or fallback_message.startswith("Merge "): + continue + commits.append( + ParsedCommit( + type="other", + scope=None, + breaking=is_breaking, + message=fallback_message, + hash=commit_hash, + ) + ) + + return commits + + +def generate_changelog(version: str, commits: list[ParsedCommit]) -> str: + """Generate markdown changelog from parsed commits.""" + today = date.today().isoformat() + lines = [f"## [{version}] - {today}", ""] + + # Collect breaking changes + breaking_commits = [c for c in commits if c.breaking] + if breaking_commits: + lines.append("### Breaking Changes") + for commit in breaking_commits: + if commit.scope: + lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})") + else: + lines.append(f"- {commit.message} ({commit.hash})") + lines.append("") + + # Group by type + by_type: dict[str, list[ParsedCommit]] = {} + for commit in commits: + by_type.setdefault(commit.type, []).append(commit) + + for commit_type, label in TYPE_LABELS.items(): + type_commits = by_type.get(commit_type, []) + if not type_commits: + continue + lines.append(f"### {label}") + for commit in type_commits: + if commit.scope: + lines.append(f"- **{commit.scope}**: {commit.message} ({commit.hash})") + else: + lines.append(f"- {commit.message} ({commit.hash})") + lines.append("") + + return "\n".join(lines) + "\n" + + +def run_git_log(since: str | None, cwd: Path) -> str: + """Run git log command and return output.""" + cmd = ["git", "log", "--first-parent", f"--pretty=format:{GIT_LOG_FORMAT}"] + if since: + cmd.append(f"{since}..HEAD") + else: + cmd.append("HEAD") + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + return result.stdout + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate changelog from conventional commits") + parser.add_argument("--version", required=True, help="Version number (e.g., 0.6.0)") + parser.add_argument("--since", help="Starting tag (exclusive)") + parser.add_argument("--dry-run", action="store_true", help="Print to stdout instead of writing") + args = parser.parse_args() + + log_output = run_git_log(args.since, ROOT) + commits = parse_commits(log_output) + changelog = generate_changelog(args.version, commits) + + if args.dry_run: + print(changelog) + else: + output_path = ROOT / ".changelog.md" + output_path.write_text(changelog, encoding="utf-8") + print(f"Changelog written to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_changelog_gen.py b/scripts/tests/test_changelog_gen.py index 2b818dbca..00ba618a8 100644 --- a/scripts/tests/test_changelog_gen.py +++ b/scripts/tests/test_changelog_gen.py @@ -1,241 +1,302 @@ -"""Tests for changelog-gen.py.""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parent.parent.parent - -# Load changelog_gen module from scripts directory (filename has hyphen) -_spec = importlib.util.spec_from_file_location( - "changelog_gen", ROOT / "scripts" / "changelog-gen.py" -) -if _spec is None: - raise ImportError("Could not load changelog_gen module") -_changelog_gen = importlib.util.module_from_spec(_spec) -if _spec.loader is None: - raise ImportError("Could not load changelog_gen module") -_spec.loader.exec_module(_changelog_gen) - -COMMIT_PATTERN = _changelog_gen.COMMIT_PATTERN -BREAKING_CHANGE_PATTERN = _changelog_gen.BREAKING_CHANGE_PATTERN -ParsedCommit = _changelog_gen.ParsedCommit -generate_changelog = _changelog_gen.generate_changelog -parse_commits = _changelog_gen.parse_commits - - -class TestParseCommits: - """Tests for parse_commits function.""" - - def test_parses_feat_commit(self) -> None: - # Format: subject|hash - log_output = "feat(core): add feature|abc1234" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "feat" - assert commits[0].scope == "core" - assert commits[0].message == "add feature" - assert commits[0].hash == "abc1234" - assert commits[0].breaking is False - - def test_parses_fix_commit(self) -> None: - log_output = "fix(ui): fix bug|def5678" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "fix" - assert commits[0].scope == "ui" - assert commits[0].message == "fix bug" - assert commits[0].hash == "def5678" - - def test_parses_ci_commit(self) -> None: - log_output = "ci: update github actions|xyz789" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "ci" - assert commits[0].scope is None - assert commits[0].message == "update github actions" - assert commits[0].hash == "xyz789" - - def test_parses_chore_commit(self) -> None: - log_output = "chore: cleanup|xyz999" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "chore" - assert commits[0].scope is None - - def test_parses_perf_commit(self) -> None: - log_output = "perf(dashboard): improve performance|abc111" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "perf" - assert commits[0].scope == "dashboard" - - def test_parses_refactor_commit(self) -> None: - log_output = "refactor(api): refactor endpoint|abc222" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "refactor" - assert commits[0].scope == "api" - - def test_parses_docs_commit(self) -> None: - log_output = "docs: update readme|abc333" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "docs" - - def test_parses_style_commit(self) -> None: - log_output = "style: format code|abc444" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "style" - - def test_parses_test_commit(self) -> None: - log_output = "test: add tests for feature|abc555" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].type == "test" - - def test_detects_breaking_change_in_body(self) -> None: - # Format: subject\nbody|hash - log_output = "feat(core): add feature\nBREAKING CHANGE: api changed|abc666" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].breaking is True - - def test_detects_breaking_change_exclamation(self) -> None: - log_output = "feat(core)!: api changed|abc777" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].breaking is True - - def test_no_scope_no_problem(self) -> None: - log_output = "feat: simple feature|abc888" - commits = parse_commits(log_output) - assert len(commits) == 1 - assert commits[0].scope is None - assert commits[0].message == "simple feature" - - -class TestGenerateChangelog: - """Tests for generate_changelog function.""" - - def test_generates_version_header(self) -> None: - commits = [ - ParsedCommit(type="feat", scope=None, breaking=False, message="test", hash="abc123") - ] - result = generate_changelog("0.6.0", commits) - assert "## [0.6.0]" in result - - def test_includes_date(self) -> None: - commits = [] - result = generate_changelog("0.6.0", commits) - import re - - date_match = re.search(r"\d{4}-\d{2}-\d{2}", result) - assert date_match is not None - - def test_groups_by_type(self) -> None: - commits = [ - ParsedCommit( - type="feat", scope=None, breaking=False, message="add feature", hash="abc123" - ), - ParsedCommit(type="fix", scope=None, breaking=False, message="fix bug", hash="def456"), - ] - result = generate_changelog("0.6.0", commits) - assert "### Features" in result - assert "### Bug Fixes" in result - assert "- add feature (abc123)" in result - assert "- fix bug (def456)" in result - - def test_includes_scope_in_bullet(self) -> None: - commits = [ - ParsedCommit( - type="feat", scope="core", breaking=False, message="add feature", hash="abc123" - ) - ] - result = generate_changelog("0.6.0", commits) - assert "- **core**: add feature (abc123)" in result - - def test_breaking_change_section_when_present(self) -> None: - commits = [ - ParsedCommit( - type="feat", scope="core", breaking=True, message="api changed", hash="abc123" - ) - ] - result = generate_changelog("0.6.0", commits) - assert "### Breaking Changes" in result - assert "**core**" in result - - def test_no_breaking_change_section_when_none(self) -> None: - commits = [ - ParsedCommit( - type="feat", scope=None, breaking=False, message="add feature", hash="abc123" - ) - ] - result = generate_changelog("0.6.0", commits) - assert "Breaking Changes" not in result - - -class TestIntegrationWithMock: - """Integration tests with mocked subprocess.run.""" - - def test_full_flow_with_mocked_git(self) -> None: - # Simulate git log output with format: subject|hash - # Breaking change in body: subject\nbody lines|hash - log_output = ( - "feat(core): add new feature|abc1234\n" - "fix(ui): fix bug|def5678\n" - "ci: update github actions|xyz789\n" - "feat(outer): breaking change\nBREAKING CHANGE: this is breaking|bbb111\n" - "chore: cleanup|yyy999" - ) - commits = parse_commits(log_output) - - assert len(commits) == 5 - assert any(c.type == "feat" and c.scope == "core" for c in commits) - assert any(c.type == "fix" and c.scope == "ui" for c in commits) - assert any(c.type == "ci" for c in commits) - assert any(c.type == "feat" and c.breaking for c in commits) - assert any(c.type == "chore" for c in commits) - - changelog = generate_changelog("0.7.0", commits) - assert "## [0.7.0]" in changelog - assert "### Features" in changelog - assert "### Bug Fixes" in changelog - assert "### CI/CD" in changelog - assert "### Breaking Changes" in changelog - - -class TestCommitPattern: - """Tests for the commit regex pattern.""" - - def test_feat_with_scope(self) -> None: - match = COMMIT_PATTERN.match("feat(core): add feature") - assert match is not None - assert match.group(1) == "feat" - assert match.group(2) == "(core)" - assert match.group(4) == "add feature" - - def test_fix_without_scope(self) -> None: - match = COMMIT_PATTERN.match("fix: fix bug") - assert match is not None - assert match.group(1) == "fix" - assert match.group(2) is None - assert match.group(4) == "fix bug" - - def test_with_exclamation(self) -> None: - match = COMMIT_PATTERN.match("feat(core)!: api changed") - assert match is not None - assert match.group(3) == "!" - - def test_without_exclamation(self) -> None: - match = COMMIT_PATTERN.match("feat(core): add feature") - assert match is not None - assert match.group(3) is None - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +"""Tests for changelog-gen.py.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent.parent + +# Load changelog_gen module from scripts directory (filename has hyphen) +_spec = importlib.util.spec_from_file_location( + "changelog_gen", ROOT / "scripts" / "changelog-gen.py" +) +if _spec is None: + raise ImportError("Could not load changelog_gen module") +_changelog_gen = importlib.util.module_from_spec(_spec) +if _spec.loader is None: + raise ImportError("Could not load changelog_gen module") +_spec.loader.exec_module(_changelog_gen) + +COMMIT_PATTERN = _changelog_gen.COMMIT_PATTERN +BREAKING_CHANGE_PATTERN = _changelog_gen.BREAKING_CHANGE_PATTERN +FIELD_SEP = _changelog_gen.FIELD_SEP +RECORD_SEP = _changelog_gen.RECORD_SEP +ParsedCommit = _changelog_gen.ParsedCommit +generate_changelog = _changelog_gen.generate_changelog +iter_commit_entries = _changelog_gen.iter_commit_entries +parse_commits = _changelog_gen.parse_commits + + +def make_log_entry(subject: str, commit_hash: str, body: str = "") -> str: + return f"{subject}{FIELD_SEP}{body}{FIELD_SEP}{commit_hash}{RECORD_SEP}" + + +class TestParseCommits: + """Tests for parse_commits function.""" + + def test_parses_feat_commit(self) -> None: + log_output = make_log_entry("feat(core): add feature", "abc1234") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "feat" + assert commits[0].scope == "core" + assert commits[0].message == "add feature" + assert commits[0].hash == "abc1234" + assert commits[0].breaking is False + + def test_parses_fix_commit(self) -> None: + log_output = make_log_entry("fix(ui): fix bug", "def5678") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "fix" + assert commits[0].scope == "ui" + assert commits[0].message == "fix bug" + assert commits[0].hash == "def5678" + + def test_parses_ci_commit(self) -> None: + log_output = make_log_entry("ci: update github actions", "xyz789") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "ci" + assert commits[0].scope is None + assert commits[0].message == "update github actions" + assert commits[0].hash == "xyz789" + + def test_parses_chore_commit(self) -> None: + log_output = make_log_entry("chore: cleanup", "xyz999") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "chore" + assert commits[0].scope is None + + def test_parses_perf_commit(self) -> None: + log_output = make_log_entry("perf(dashboard): improve performance", "abc111") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "perf" + assert commits[0].scope == "dashboard" + + def test_parses_refactor_commit(self) -> None: + log_output = make_log_entry("refactor(api): refactor endpoint", "abc222") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "refactor" + assert commits[0].scope == "api" + + def test_parses_docs_commit(self) -> None: + log_output = make_log_entry("docs: update readme", "abc333") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "docs" + + def test_parses_style_commit(self) -> None: + log_output = make_log_entry("style: format code", "abc444") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "style" + + def test_parses_test_commit(self) -> None: + log_output = make_log_entry("test: add tests for feature", "abc555") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "test" + + def test_detects_breaking_change_in_body(self) -> None: + log_output = make_log_entry( + "feat(core): add feature", + "abc666", + "BREAKING CHANGE: api changed", + ) + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].breaking is True + + def test_detects_breaking_change_exclamation(self) -> None: + log_output = make_log_entry("feat(core)!: api changed", "abc777") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].breaking is True + + def test_no_scope_no_problem(self) -> None: + log_output = make_log_entry("feat: simple feature", "abc888") + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].scope is None + assert commits[0].message == "simple feature" + + def test_uses_pr_title_from_merge_commit_body(self) -> None: + log_output = make_log_entry( + "Merge pull request #173 from JerrettDavis/fix/pipeline-permissions-and-docs", + "73f6673", + "fix: repair release and docs pipelines", + ) + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "fix" + assert commits[0].message == "repair release and docs pipelines" + + def test_falls_back_to_other_changes_for_non_conventional_merge(self) -> None: + log_output = make_log_entry( + "Merge pull request #186 from skorokithakis/patch-1", + "1e80ee3", + "Add support for custom Anthropic API URL", + ) + commits = parse_commits(log_output) + assert len(commits) == 1 + assert commits[0].type == "other" + assert commits[0].message == "Add support for custom Anthropic API URL" + + def test_iter_commit_entries_parses_real_git_log_delimiters(self) -> None: + log_output = make_log_entry( + "fix: patch release flow", "abc1234", "BREAKING CHANGE: no" + ) + make_log_entry("docs: update readme", "def5678") + assert iter_commit_entries(log_output) == [ + ("fix: patch release flow", "BREAKING CHANGE: no", "abc1234"), + ("docs: update readme", "", "def5678"), + ] + + def test_iter_commit_entries_keeps_field_separator_inside_body(self) -> None: + body = f"line one{FIELD_SEP}line two" + log_output = make_log_entry("fix: patch release flow", "abc1234", body) + assert iter_commit_entries(log_output) == [ + ("fix: patch release flow", body, "abc1234"), + ] + + +class TestGenerateChangelog: + """Tests for generate_changelog function.""" + + def test_generates_version_header(self) -> None: + commits = [ + ParsedCommit(type="feat", scope=None, breaking=False, message="test", hash="abc123") + ] + result = generate_changelog("0.6.0", commits) + assert "## [0.6.0]" in result + + def test_includes_date(self) -> None: + commits = [] + result = generate_changelog("0.6.0", commits) + import re + + date_match = re.search(r"\d{4}-\d{2}-\d{2}", result) + assert date_match is not None + + def test_groups_by_type(self) -> None: + commits = [ + ParsedCommit( + type="feat", scope=None, breaking=False, message="add feature", hash="abc123" + ), + ParsedCommit(type="fix", scope=None, breaking=False, message="fix bug", hash="def456"), + ] + result = generate_changelog("0.6.0", commits) + assert "### Features" in result + assert "### Bug Fixes" in result + assert "- add feature (abc123)" in result + assert "- fix bug (def456)" in result + + def test_includes_scope_in_bullet(self) -> None: + commits = [ + ParsedCommit( + type="feat", scope="core", breaking=False, message="add feature", hash="abc123" + ) + ] + result = generate_changelog("0.6.0", commits) + assert "- **core**: add feature (abc123)" in result + + def test_breaking_change_section_when_present(self) -> None: + commits = [ + ParsedCommit( + type="feat", scope="core", breaking=True, message="api changed", hash="abc123" + ) + ] + result = generate_changelog("0.6.0", commits) + assert "### Breaking Changes" in result + assert "**core**" in result + + def test_no_breaking_change_section_when_none(self) -> None: + commits = [ + ParsedCommit( + type="feat", scope=None, breaking=False, message="add feature", hash="abc123" + ) + ] + result = generate_changelog("0.6.0", commits) + assert "Breaking Changes" not in result + + def test_includes_other_changes_section(self) -> None: + commits = [ + ParsedCommit( + type="other", + scope=None, + breaking=False, + message="Add support for custom Anthropic API URL", + hash="abc123", + ) + ] + result = generate_changelog("0.6.0", commits) + assert "### Other Changes" in result + assert "- Add support for custom Anthropic API URL (abc123)" in result + + +class TestIntegrationWithMock: + """Integration tests with mocked subprocess.run.""" + + def test_full_flow_with_mocked_git(self) -> None: + log_output = ( + make_log_entry("feat(core): add new feature", "abc1234") + + make_log_entry("fix(ui): fix bug", "def5678") + + make_log_entry("ci: update github actions", "xyz789") + + make_log_entry( + "feat(outer): breaking change", "bbb111", "BREAKING CHANGE: this is breaking" + ) + + make_log_entry("chore: cleanup", "yyy999") + ) + commits = parse_commits(log_output) + + assert len(commits) == 5 + assert any(c.type == "feat" and c.scope == "core" for c in commits) + assert any(c.type == "fix" and c.scope == "ui" for c in commits) + assert any(c.type == "ci" for c in commits) + assert any(c.type == "feat" and c.breaking for c in commits) + assert any(c.type == "chore" for c in commits) + + changelog = generate_changelog("0.7.0", commits) + assert "## [0.7.0]" in changelog + assert "### Features" in changelog + assert "### Bug Fixes" in changelog + assert "### CI/CD" in changelog + assert "### Breaking Changes" in changelog + + +class TestCommitPattern: + """Tests for the commit regex pattern.""" + + def test_feat_with_scope(self) -> None: + match = COMMIT_PATTERN.match("feat(core): add feature") + assert match is not None + assert match.group(1) == "feat" + assert match.group(2) == "(core)" + assert match.group(4) == "add feature" + + def test_fix_without_scope(self) -> None: + match = COMMIT_PATTERN.match("fix: fix bug") + assert match is not None + assert match.group(1) == "fix" + assert match.group(2) is None + assert match.group(4) == "fix bug" + + def test_with_exclamation(self) -> None: + match = COMMIT_PATTERN.match("feat(core)!: api changed") + assert match is not None + assert match.group(3) == "!" + + def test_without_exclamation(self) -> None: + match = COMMIT_PATTERN.match("feat(core): add feature") + assert match is not None + assert match.group(3) is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index d40a9f429..9ded57ac8 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -1,6 +1,12 @@ """Tests for release version normalization and bumping.""" -from headroom.release_version import compute_release_version, normalize_release_tag +import pytest + +from headroom.release_version import ( + compute_release_version, + find_latest_release_tag, + normalize_release_tag, +) def test_normalize_release_tag_preserves_three_part_tag() -> None: @@ -61,3 +67,17 @@ def test_manual_version_override_uses_single_semver() -> None: assert info.npm_version == "0.6.0" assert info.previous_tag == "" assert info.bump == "manual" + + +def test_manual_version_override_rejects_legacy_four_part_version() -> None: + with pytest.raises(ValueError, match="Invalid semantic version"): + compute_release_version( + canonical_version="0.5.25", + level="patch", + tags=["v0.5.25.2"], + manual_version="0.5.25.3", + ) + + +def test_find_latest_release_tag_prefers_highest_normalized_version() -> None: + assert find_latest_release_tag(["v0.5.25.2", "v0.5.27", "not-a-tag"]) == "v0.5.27"