fix: harden release notes and semver

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
JerrettDavis 2026-04-16 18:50:36 -05:00
parent bc9deec657
commit c24b1fa46f
4 changed files with 529 additions and 385 deletions

View file

@ -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",

View file

@ -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()

View file

@ -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"])

View file

@ -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"