Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions pretty_release_notes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
)
Expand Down
1 change: 1 addition & 0 deletions pretty_release_notes/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
26 changes: 25 additions & 1 deletion pretty_release_notes/models/_utils.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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))
7 changes: 6 additions & 1 deletion pretty_release_notes/models/commit.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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

Expand Down
14 changes: 13 additions & 1 deletion pretty_release_notes/models/pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion pretty_release_notes/models/release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand All @@ -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] = []
Expand All @@ -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"]

Expand Down
51 changes: 50 additions & 1 deletion tests/test_models_utils.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Loading