diff --git a/pretty_release_notes/api.py b/pretty_release_notes/api.py index 6307770..b945d40 100644 --- a/pretty_release_notes/api.py +++ b/pretty_release_notes/api.py @@ -158,6 +158,19 @@ def with_progress_reporter(self, reporter: ProgressReporter) -> "ReleaseNotesBui self._progress_reporter = reporter return self + def _build_grouping_config(self) -> GroupingConfig: + """Build grouping config, preserving defaults when no custom headings are set.""" + if self._type_headings: + return GroupingConfig( + group_by_type=self._group_by_type, + type_headings=self._type_headings, + other_heading=self._other_heading, + ) + return GroupingConfig( + group_by_type=self._group_by_type, + other_heading=self._other_heading, + ) + def build(self) -> ReleaseNotesClient: """Build the client with configured options. @@ -186,11 +199,7 @@ def build(self) -> ReleaseNotesClient: exclude_change_labels=self._exclude_labels, exclude_authors=self._exclude_authors, ), - grouping=GroupingConfig( - group_by_type=self._group_by_type, - **({} if not self._type_headings else {"type_headings": self._type_headings}), - other_heading=self._other_heading, - ), + grouping=self._build_grouping_config(), prompt_path=self._prompt_path, force_use_commits=self._force_use_commits, ) diff --git a/pretty_release_notes/core/config.py b/pretty_release_notes/core/config.py index 570028f..69d677c 100644 --- a/pretty_release_notes/core/config.py +++ b/pretty_release_notes/core/config.py @@ -71,6 +71,7 @@ class GroupingConfig: } ) other_heading: str = "Other Changes" + breaking_changes_heading: str = "Breaking Changes" def get_heading(self, type_name: str | None) -> str: """Get the section heading for a given type.""" diff --git a/pretty_release_notes/models/_utils.py b/pretty_release_notes/models/_utils.py index 13df6ef..b8eb8e2 100644 --- a/pretty_release_notes/models/_utils.py +++ b/pretty_release_notes/models/_utils.py @@ -1,6 +1,7 @@ import re -CONVENTIONAL_TYPE_AND_SCOPE = re.compile(r"^([a-zA-Z]{2,8})(?:\(([^)]+)\))?:") +CONVENTIONAL_TYPE_AND_SCOPE = re.compile(r"^([a-zA-Z]{2,8})(?:\(([^)]+)\))?!?:") +BREAKING_CHANGE_PATTERN = re.compile(r"^[a-zA-Z]{2,8}(?:\([^)]+\))?!:") def get_conventional_type(msg: str) -> str | None: @@ -9,6 +10,7 @@ def get_conventional_type(msg: str) -> str | None: Examples: 'feat(regional): Address Template for Germany & Switzerland' -> 'feat' 'Revert "perf: timeout while renaming cost center"' -> None + 'fix!: breaking change' -> 'fix' """ if not msg: return None @@ -21,3 +23,25 @@ def get_conventional_type(msg: str) -> str | None: match = CONVENTIONAL_TYPE_AND_SCOPE.match(msg) return match.group(1).lower() if match else None + + +def is_breaking_change(msg: str) -> bool: + """Check if a message indicates a breaking change. + + Breaking changes are indicated by an exclamation mark (!) after the type/scope + and before the colon, following the Conventional Commits specification. + + Examples: + 'feat!: breaking feature' -> True + 'fix(api)!: breaking fix' -> True + 'feat: regular feature' -> False + """ + if not msg: + return False + + # Strip leading/trailing whitespace + msg = msg.strip() + + # Check for the ! indicator before the colon + # Pattern: type(scope)!: or type!: + return bool(BREAKING_CHANGE_PATTERN.match(msg)) diff --git a/pretty_release_notes/models/commit.py b/pretty_release_notes/models/commit.py index a666670..531ba44 100644 --- a/pretty_release_notes/models/commit.py +++ b/pretty_release_notes/models/commit.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from ._utils import get_conventional_type +from ._utils import get_conventional_type, is_breaking_change from .change import Change if TYPE_CHECKING: @@ -24,6 +24,11 @@ class Commit(Change): def conventional_type(self) -> str | None: return get_conventional_type(self.message) + @property + def is_breaking(self) -> bool: + """Check if this commit represents a breaking change.""" + return is_breaking_change(self.message) + def get_prompt(self, prompt_template: str, max_patch_size: int) -> str: prompt = prompt_template diff --git a/pretty_release_notes/models/pull_request.py b/pretty_release_notes/models/pull_request.py index e751099..071834c 100644 --- a/pretty_release_notes/models/pull_request.py +++ b/pretty_release_notes/models/pull_request.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from ._utils import get_conventional_type +from ._utils import get_conventional_type, is_breaking_change from .change import Change if TYPE_CHECKING: @@ -76,9 +76,21 @@ def conventional_type(self) -> str | None: Examples: 'feat(regional): Address Template for Germany & Switzerland' -> 'feat' 'Revert "perf: timeout while renaming cost center"' -> None + 'fix!: breaking change' -> 'fix' """ return get_conventional_type(self.title) + @property + def is_breaking(self) -> bool: + """Check if this PR represents a breaking change. + + Examples: + 'feat!: breaking feature' -> True + 'fix(api)!: breaking fix' -> True + 'feat: regular feature' -> False + """ + return is_breaking_change(self.title) + def get_prompt(self, prompt_template: str, max_patch_size: int) -> str: prompt = prompt_template diff --git a/pretty_release_notes/models/release_notes.py b/pretty_release_notes/models/release_notes.py index 6d3d41f..c347ee6 100644 --- a/pretty_release_notes/models/release_notes.py +++ b/pretty_release_notes/models/release_notes.py @@ -112,6 +112,7 @@ def is_reverted_or_revert(change): if grouping and grouping.group_by_type: # Group lines by type grouped_lines: dict[str, list[ReleaseNotesLine]] = {} + breaking_lines: list[ReleaseNotesLine] = [] other_lines: list[ReleaseNotesLine] = [] new_contributor_lines: list[ReleaseNotesLine] = [] @@ -129,8 +130,11 @@ def is_reverted_or_revert(change): ): continue + # Check for breaking changes first + if line.change and hasattr(line.change, "is_breaking") and line.change.is_breaking: + breaking_lines.append(line) # Group by conventional type - if line.change and line.change.conventional_type: + elif line.change and line.change.conventional_type: type_key = line.change.conventional_type if type_key not in grouped_lines: grouped_lines[type_key] = [] @@ -142,6 +146,13 @@ def is_reverted_or_revert(change): # Build grouped output sections = [] + # Add breaking changes section first (highest priority) + if breaking_lines: + sections.append(f"## {grouping.breaking_changes_heading}") + for line in breaking_lines: + sections.append(str(line)) + sections.append("") # Empty line after section + # Add sections in a consistent order type_order = ["feat", "fix", "perf", "docs", "refactor", "test", "build", "ci", "chore", "style", "revert"] diff --git a/tests/test_models_utils.py b/tests/test_models_utils.py index 3395eee..c0eaa55 100644 --- a/tests/test_models_utils.py +++ b/tests/test_models_utils.py @@ -1,6 +1,6 @@ """Tests for models utility functions.""" -from pretty_release_notes.models._utils import get_conventional_type +from pretty_release_notes.models._utils import get_conventional_type, is_breaking_change class TestGetConventionalType: @@ -68,3 +68,52 @@ def test_real_world_examples(self): assert get_conventional_type('Revert "perf: timeout while renaming cost center"') is None assert get_conventional_type(" Fix: Product Bundle Purchase Order Creation Logic") == "fix" assert get_conventional_type("fix(accounting): correct tax calculation") == "fix" + + def test_breaking_changes_with_exclamation(self): + """Test that breaking changes with ! are still parsed correctly.""" + assert get_conventional_type("feat!: breaking feature") == "feat" + assert get_conventional_type("fix!: breaking fix") == "fix" + assert get_conventional_type("fix(api)!: breaking fix with scope") == "fix" + assert get_conventional_type("perf!: breaking performance improvement") == "perf" + + +class TestIsBreakingChange: + """Test breaking change detection.""" + + def test_breaking_changes_with_exclamation(self): + """Test detection of breaking changes with ! indicator.""" + assert is_breaking_change("feat!: breaking feature") is True + assert is_breaking_change("fix!: breaking fix") is True + assert is_breaking_change("perf!: breaking performance improvement") is True + + def test_breaking_changes_with_scope(self): + """Test detection of breaking changes with scope.""" + assert is_breaking_change("feat(api)!: breaking API change") is True + assert is_breaking_change("fix(core)!: breaking fix in core") is True + assert is_breaking_change("refactor(ui)!: breaking UI refactor") is True + + def test_non_breaking_changes(self): + """Test that regular changes are not detected as breaking.""" + assert is_breaking_change("feat: regular feature") is False + assert is_breaking_change("fix: regular fix") is False + assert is_breaking_change("fix(api): regular fix with scope") is False + + def test_case_insensitive(self): + """Test that breaking change detection is case-insensitive.""" + assert is_breaking_change("FEAT!: breaking feature") is True + assert is_breaking_change("Fix!: breaking fix") is True + assert is_breaking_change("Perf(api)!: breaking perf") is True + + def test_with_whitespace(self): + """Test that whitespace doesn't affect detection.""" + assert is_breaking_change(" feat!: breaking feature") is True + assert is_breaking_change(" fix!: breaking fix ") is True + assert is_breaking_change("\tfeat(api)!: breaking change") is True + + def test_edge_cases(self): + """Test edge cases.""" + assert is_breaking_change("") is False + assert is_breaking_change(" ") is False + assert is_breaking_change("feat: no exclamation") is False + assert is_breaking_change("feat :! wrong format") is False + assert is_breaking_change("feat (scope)!: space before scope") is False diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index 96f6769..7075ecd 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -443,3 +443,195 @@ def test_new_contributors_without_grouping(): # Should preserve new contributor line in flat list assert "Added feature" in output assert "@newuser made their first contribution" in output + + +def test_breaking_changes_section(): + """Test that breaking changes are grouped in their own section.""" + github = GitHubClient("test_token") + repo = Repository( + owner="test_org", + name="test_repo", + url="https://api.github.com/repos/test_org/test_repo", + html_url="https://github.com/test_org/test_repo", + ) + + # Create PRs with breaking changes + breaking_feat_pr = PullRequest( + github=github, + repository=repo, + id=1, + title="feat!: breaking feature change", + body="", + html_url="https://github.com/org/repo/pull/1", + ) + + breaking_fix_pr = PullRequest( + github=github, + repository=repo, + id=2, + title="fix(api)!: breaking API fix", + body="", + html_url="https://github.com/org/repo/pull/2", + ) + + # Regular PRs + feat_pr = PullRequest( + github=github, + repository=repo, + id=3, + title="feat: regular feature", + body="", + html_url="https://github.com/org/repo/pull/3", + ) + + fix_pr = PullRequest( + github=github, + repository=repo, + id=4, + title="fix: regular fix", + body="", + html_url="https://github.com/org/repo/pull/4", + ) + + # Create release notes + lines = [ + ReleaseNotesLine(original_line="", change=breaking_feat_pr, sentence="Breaking feature change"), + ReleaseNotesLine(original_line="", change=breaking_fix_pr, sentence="Breaking API fix"), + ReleaseNotesLine(original_line="", change=feat_pr, sentence="Regular feature"), + ReleaseNotesLine(original_line="", change=fix_pr, sentence="Regular fix"), + ] + + release_notes = ReleaseNotes(lines=lines) + + # Test with grouping enabled + grouping = GroupingConfig(group_by_type=True) + output = release_notes.serialize(grouping=grouping) + + # Verify breaking changes section exists + assert "## Breaking Changes" in output + + # Verify other sections exist + assert "## Features" in output + assert "## Bug Fixes" in output + + # Verify items are in correct sections + lines_output = output.split("\n") + breaking_section_start = lines_output.index("## Breaking Changes") + feat_section_start = lines_output.index("## Features") + fix_section_start = lines_output.index("## Bug Fixes") + + # Breaking changes should come first + assert breaking_section_start < feat_section_start + assert breaking_section_start < fix_section_start + + # Breaking changes should be in Breaking Changes section + breaking_text = "\n".join(lines_output[breaking_section_start:feat_section_start]) + assert "Breaking feature change" in breaking_text + assert "Breaking API fix" in breaking_text + + # Regular changes should NOT be in Breaking Changes section + assert "Regular feature" not in breaking_text + assert "Regular fix" not in breaking_text + + # Regular changes should be in their respective sections + features_text = "\n".join(lines_output[feat_section_start:fix_section_start]) + assert "Regular feature" in features_text + + fixes_text = "\n".join(lines_output[fix_section_start:]) + assert "Regular fix" in fixes_text + + +def test_breaking_changes_without_grouping(): + """Test that breaking changes appear normally when grouping is disabled.""" + github = GitHubClient("test_token") + repo = Repository( + owner="test_org", + name="test_repo", + url="https://api.github.com/repos/test_org/test_repo", + html_url="https://github.com/test_org/test_repo", + ) + + breaking_pr = PullRequest( + github=github, + repository=repo, + id=1, + title="feat!: breaking change", + body="", + html_url="https://github.com/org/repo/pull/1", + ) + + regular_pr = PullRequest( + github=github, + repository=repo, + id=2, + title="feat: regular change", + body="", + html_url="https://github.com/org/repo/pull/2", + ) + + lines = [ + ReleaseNotesLine(original_line="", change=breaking_pr, sentence="Breaking change"), + ReleaseNotesLine(original_line="", change=regular_pr, sentence="Regular change"), + ] + + release_notes = ReleaseNotes(lines=lines) + + # Test without grouping + output = release_notes.serialize() + + # Should not have section headers + assert "## Breaking Changes" not in output + assert "## Features" not in output + + # Both should appear as flat list + assert "Breaking change" in output + assert "Regular change" in output + + +def test_breaking_changes_with_filtering(): + """Test that breaking changes respect filtering rules.""" + github = GitHubClient("test_token") + repo = Repository( + owner="test_org", + name="test_repo", + url="https://api.github.com/repos/test_org/test_repo", + html_url="https://github.com/test_org/test_repo", + ) + + # Breaking chore (should be filtered) + breaking_chore_pr = PullRequest( + github=github, + repository=repo, + id=1, + title="chore!: breaking chore change", + body="", + html_url="https://github.com/org/repo/pull/1", + ) + + # Breaking feat (should be included) + breaking_feat_pr = PullRequest( + github=github, + repository=repo, + id=2, + title="feat!: breaking feature", + body="", + html_url="https://github.com/org/repo/pull/2", + ) + + lines = [ + ReleaseNotesLine(original_line="", change=breaking_chore_pr, sentence="Breaking chore"), + ReleaseNotesLine(original_line="", change=breaking_feat_pr, sentence="Breaking feature"), + ] + + release_notes = ReleaseNotes(lines=lines) + + # Test with grouping and filtering + grouping = GroupingConfig(group_by_type=True) + output = release_notes.serialize(exclude_change_types={"chore"}, grouping=grouping) + + # Breaking chore should be filtered + assert "Breaking chore" not in output + + # Breaking feature should be included + assert "## Breaking Changes" in output + assert "Breaking feature" in output