Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d5fa3d6
Initial solution.
miroslavpojer Jun 26, 2026
5afa957
fix: improve error handling for missing tags in compare mode
miroslavpojer Jun 26, 2026
df399f7
Fixed review comment.
miroslavpojer Jun 26, 2026
f4e2d16
Fixed review comments.
miroslavpojer Jun 26, 2026
94c0504
fix: simplify error message formatting for GitHub API tag checks
miroslavpojer Jun 26, 2026
da97ff2
fix: remove tag validation logic from DataMiner and related tests
miroslavpojer Jun 29, 2026
26b316a
fix: update from-tag-name validation to include whitespace-only values
miroslavpojer Jun 29, 2026
c835e38
fix: enhance compare mode tag validation and error handling
miroslavpojer Jun 29, 2026
6116859
fix: update compare mode validation logic and improve documentation
miroslavpojer Jun 29, 2026
10cb260
fix: improve tag validation in compare mode and enhance error handling
miroslavpojer Jun 29, 2026
2f47802
fix: clarify fail-safe behavior in compare mode documentation
miroslavpojer Jun 29, 2026
f5e6830
fix: enhance error handling in compare mode for network exceptions an…
miroslavpojer Jun 29, 2026
3b311cf
fix: remove obsolete SPEC.md file from release notes generator
miroslavpojer Jun 29, 2026
982128f
fix: enhance compare mode functionality by adding from-tag-name input…
miroslavpojer Jun 29, 2026
cd2cfbc
Fix review comments.
miroslavpojer Jun 30, 2026
832242f
Simplified logic - based on review comments.
miroslavpojer Jun 30, 2026
960764e
Fix review comments.
miroslavpojer Jun 30, 2026
fa1a6f1
Update of tests.
miroslavpojer Jun 30, 2026
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
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
* Ensure the Pull Request description clearly outlines your solution.
* Link your PR to the relevant _Issue_.

## Compare Mode Contract

When contributing changes that touch compare mode (the `from-tag-name` path):
Comment thread
tmikula-dev marked this conversation as resolved.
Resolved
- Both `tag-name` and `from-tag-name` must exist as git tags before the compare API is called.
Comment thread
tmikula-dev marked this conversation as resolved.
Resolved
- The action validates this via `DataMiner._validate_compare_mode_tags()` and exits with code 1 if either tag is absent.
- Any change to this validation logic must update `docs/features/compare_mode.md` and the tests in `tests/unit/release_notes_generator/data/test_miner.py`.
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved

## Branch Naming (PID:H-1)
Branches MUST start with one of the allowed prefixes: `feature/`, `fix/`, `docs/`, `chore/`
Examples:
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ Only a few inputs are required to get started:
For the full input and output reference, see [Configuration reference](docs/configuration_reference.md).
For how label → chapter mapping and aggregation works, see [Custom Chapters Behavior](docs/configuration_reference.md#custom-chapters-behavior).

> **Important**: tag defined by `tag-name` must exist in the repository; otherwise, the action fails.
> **Important**: In compare mode (`from-tag-name` provided), **both** `tag-name` and
> `from-tag-name` must exist as git tags in the repository. The action validates this
> before calling the compare API and exits with a clear error if either is absent.
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved

## Example Workflow

Expand Down
4 changes: 3 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ inputs:
required: false
default: 'both'
from-tag-name:
description: 'The tag name of the previous release to use as a start reference point for the current release notes.'
description: |
The tag name of the previous release to use as a start reference point for the current release notes.
When provided, activates compare mode. Both this tag and 'tag-name' must exist as git tags.
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
required: false
default: ''
hierarchy:
Expand Down
15 changes: 11 additions & 4 deletions docs/features/compare_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Generating release notes for **`v2.6.5`** (previous: `v2.6.4`):
Compare mode is active **when `from-tag-name` is explicitly provided**. When it is absent
the existing timestamp path runs unchanged.

> **Prerequisite — both tags must exist:** Before the compare API is called, the action
> validates that **both `from-tag-name` and `tag-name` exist as git tags** in the repository.
> If either tag is absent the action exits immediately with a clear error message.
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved

### Step 1 — Graph-based commit selection

Instead of asking "what happened after time T?", the action asks GitHub: *"what commits
Expand Down Expand Up @@ -106,11 +110,14 @@ from-tag-name provided?
┌──┴──────────────────────┐
YES (compare mode) NO (timestamp mode)
│ │
GitHub Compare API: get_commits(since=data.since)
commits unique to to-tag get_pulls(state=closed)
Validate both tags exist get_commits(since=data.since)
(exit with error if missing) get_pulls(state=closed)
│ │
extract PR numbers FilterByRelease drops
from commit messages PRs/commits before since
GitHub Compare API: FilterByRelease drops
commits unique to to-tag PRs/commits before since
│
extract PR numbers
from commit messages
│
fetch each PR by number
│
Expand Down
2 changes: 1 addition & 1 deletion docs/motivation.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Release documentation often drifts: missing PR summaries, unlabeled issues, or m
|-----------|-------------|
| Determinism | Same inputs produce the same release notes; no hidden heuristics. |
| Explicit Configuration | Chapters defined in YAML; no magic label groupings. |
| Fail Safe | If explicit notes missing, optionally fall back to CodeRabbit; never silently fabricate content. |
| Fail Safe | If explicit notes missing, optionally fall back to CodeRabbit; never silently fabricate content. In compare mode, both tags must exist before any API call — the action exits immediately with a clear error rather than confusing 404 responses. |
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
| Transparency | Service Chapters surface hygiene issues instead of hiding them. |
| Extensibility | Row formats, hierarchy, and duplicity policy are pluggable via inputs. |
| Minimal Boilerplate | Only a tag and basic chapters required for first adoption. |
Expand Down
23 changes: 23 additions & 0 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ def is_from_tag_name_defined() -> bool:
value = ActionInputs.get_from_tag_name()
return value.strip() != ""

@staticmethod
def validate_compare_mode_tag_names() -> None:
"""
Validate that both tag-name and from-tag-name are non-empty strings.

Logs an error and exits if either is empty. Called before GitHub tag-existence
checks so callers receive a clear input-level error before any API call.
Both 'tag-name' and 'from-tag-name' must be non-empty when running in compare mode.
"""
tag = ActionInputs.get_tag_name()
if not tag.strip():
Comment thread
tmikula-dev marked this conversation as resolved.
Resolved
logger.error("'tag-name' must not be empty when running in compare mode. Ending!")
sys.exit(1)
from_tag = ActionInputs.get_from_tag_name()
if not from_tag.strip():
logger.error("'from-tag-name' must not be empty when running in compare mode. Ending!")
sys.exit(1)
Comment thread
tmikula-dev marked this conversation as resolved.
Resolved

@staticmethod
def get_chapters() -> list[dict[str, str]]:
"""
Expand Down Expand Up @@ -669,6 +687,11 @@ def validate_inputs() -> None:
print_empty_chapters = ActionInputs.get_print_empty_chapters()
ActionInputs.validate_input(print_empty_chapters, bool, "Print empty chapters must be a boolean.", errors)

# Compare mode: validate both tag names are non-empty strings.
# Only runs when all prior validations passed to avoid cascading errors.
if not errors and isinstance(from_tag_name, str) and from_tag_name.strip():
ActionInputs.validate_compare_mode_tag_names()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# Log errors if any
if errors:
for error in errors:
Expand Down
24 changes: 24 additions & 0 deletions release_notes_generator/data/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from release_notes_generator.action_inputs import ActionInputs
from release_notes_generator.data.utils.bulk_sub_issue_collector import BulkSubIssueCollector
from release_notes_generator.utils.constants import COMPARE_MODE_TAG_MISSING_ERROR

from release_notes_generator.model.record.issue_record import IssueRecord
from release_notes_generator.model.mined_data import MinedData
Expand Down Expand Up @@ -93,15 +94,38 @@ def mine_data(self) -> MinedData:

return de_duplicated_data

def _validate_tag_exists(self, repo: Repository, tag_name: str) -> bool:
"""
Return True if *tag_name* exists as a git tag in *repo*, False otherwise.

Uses the safe_call wrapper so any 404 / GithubException is caught and
converted to a None return value, which is treated as "not found".
"""
ref = self._safe_call(repo.get_git_ref)(f"tags/{tag_name}")
return ref is not None
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved

def _validate_compare_mode_tags(self, repo: Repository) -> None:
"""
Validate that both from-tag-name and tag-name exist as git tags.

Exits with code 1 on the first missing tag, logging a clear error message.
"""
for tag_name in (ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()):
if not self._validate_tag_exists(repo, tag_name):
logger.error(COMPARE_MODE_TAG_MISSING_ERROR, tag_name, repo.full_name)
sys.exit(1)

def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:
"""
Handle comparison mode: mine commits and PRs between two tags.

Logic:
- Validate both from_tag and to_tag exist as git tags (fail fast).
- Fetch commits between from_tag and to_tag using repo.compare().
- Extract PR numbers from commit messages and fetch those PRs.
- Filter out commits that already have a PR reference to avoid duplication.
"""
self._validate_compare_mode_tags(repo)
logger.info(
"Compare mode: using repo.compare('%s', '%s').",
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
ActionInputs.get_from_tag_name(),
Expand Down
6 changes: 6 additions & 0 deletions release_notes_generator/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@
OTHERS_NO_TOPIC,
]

# Compare mode tag validation
COMPARE_MODE_TAG_MISSING_ERROR: str = (
"Compare mode requires both tags to exist. Tag '%s' not found in repository '%s'."
" Both 'tag-name' and 'from-tag-name' must exist as git tags. Ending!"
)

LINKED_ISSUES_MAX = 10
ISSUES_FOR_PRS: str = """
query {{
Expand Down
35 changes: 34 additions & 1 deletion tests/unit/release_notes_generator/data/test_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,8 @@ def test_extract_pr_numbers_multiline_message(mocker):

def _make_compare_miner(mocker, mock_repo, *, from_tag="v2.6.3", to_tag="v2.6.4",
created_at=datetime(2026, 5, 7), published_at=None, prefer_published=False,
compare_commits=None, get_pull_side_effect=None, total_commits=None):
compare_commits=None, get_pull_side_effect=None, total_commits=None,
from_tag_ref_exists=True, to_tag_ref_exists=True):
"""Wire a DataMiner for compare-mode mine_data calls."""
mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=True)
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_from_tag_name", return_value=from_tag)
Expand All @@ -589,6 +590,13 @@ def _make_compare_miner(mocker, mock_repo, *, from_tag="v2.6.3", to_tag="v2.6.4"
mock_repo.get_release.return_value = release_mock
mock_repo.get_issues.return_value = []

# Simulate git-ref existence for each tag in call order (from_tag first, to_tag second)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
_ref_results = [
mocker.Mock() if from_tag_ref_exists else None,
mocker.Mock() if to_tag_ref_exists else None,
]
mock_repo.get_git_ref.side_effect = _ref_results

comparison_mock = mocker.Mock()
comparison_mock.commits = compare_commits if compare_commits is not None else []
if total_commits is not None:
Expand Down Expand Up @@ -786,3 +794,28 @@ def test_mine_data_timestamp_mode_compare_shas_empty(mocker, mock_repo):
data = miner.mine_data()

assert data.compare_commit_shas == set()


# --- compare mode tag validation (end-to-end via mine_data) ---


def test_mine_data_compare_mode_exits_when_target_tag_missing(mocker, mock_repo):
"""End-to-end: mine_data exits when tag-name does not exist as a git tag."""
miner = _make_compare_miner(mocker, mock_repo, to_tag_ref_exists=False)
mocker.patch("sys.exit", side_effect=SystemExit(1))

Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
with pytest.raises(SystemExit):
miner.mine_data()

mock_repo.compare.assert_not_called()


def test_mine_data_compare_mode_exits_when_from_tag_missing(mocker, mock_repo):
"""End-to-end: mine_data exits when from-tag-name does not exist as a git tag."""
miner = _make_compare_miner(mocker, mock_repo, from_tag_ref_exists=False)
mocker.patch("sys.exit", side_effect=SystemExit(1))

with pytest.raises(SystemExit):
miner.mine_data()

mock_repo.compare.assert_not_called()
40 changes: 40 additions & 0 deletions tests/unit/release_notes_generator/test_action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,46 @@ def test_get_from_tag_name_invalid_format(mocker):
)


# --- validate_compare_mode_tag_names ---


def test_validate_compare_mode_tag_names_both_set(mocker):
"""Both tag-name and from-tag-name non-empty → no exit, no error."""
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_tag_name", return_value="v1.1.0")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_from_tag_name", return_value="v1.0.0")
mock_exit = mocker.patch("sys.exit")

ActionInputs.validate_compare_mode_tag_names()

mock_exit.assert_not_called()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


def test_validate_compare_mode_tag_names_empty_tag_name_exits(mocker):
"""Empty tag-name → sys.exit(1)."""
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_tag_name", return_value="")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_from_tag_name", return_value="v1.0.0")
mock_exit = mocker.patch("sys.exit")
error_mock = mocker.patch("release_notes_generator.action_inputs.logger.error")

ActionInputs.validate_compare_mode_tag_names()

mock_exit.assert_called_once_with(1)
assert any("tag-name" in str(c) for c in error_mock.call_args_list)


def test_validate_compare_mode_tag_names_empty_from_tag_name_exits(mocker):
"""Empty from-tag-name → sys.exit(1)."""
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_tag_name", return_value="v1.1.0")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_from_tag_name", return_value="")
mock_exit = mocker.patch("sys.exit")
error_mock = mocker.patch("release_notes_generator.action_inputs.logger.error")

ActionInputs.validate_compare_mode_tag_names()

mock_exit.assert_called_once_with(1)
assert any("from-tag-name" in str(c) for c in error_mock.call_args_list)


def test_get_chapters_success(mocker):
mocker.patch(
"release_notes_generator.action_inputs.get_action_input", return_value='[{"title": "Title", "label": "Label"}]'
Expand Down
Loading