Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
* 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.
Outdated
- 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.
Outdated
- Input-level check: `ActionInputs.validate_compare_mode_tag_names()` (called from `validate_inputs()`) exits with code 1 if either input string is empty.
- Tag-existence check: `DataMiner._handle_compare_mode()` calls `repo.get_git_ref("tags/<tag>")` for both tags and exits with code 1 naming the absent tag if either lookup fails.
- 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
tmikula-dev marked this conversation as resolved.
Outdated

## Branch Naming (PID:H-1)
Branches MUST start with one of the allowed prefixes: `feature/`, `fix/`, `docs/`, `chore/`
Examples:
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ 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 checks each tag
> via the GitHub API before calling compare and exits with a tag-specific error if either
> is absent.

## 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.
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
> looks up each tag via `get_git_ref("tags/<tag>")`. If either tag is absent the action
> exits immediately with a tag-specific error message naming the missing tag.

### 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 the compare API is called — the action exits immediately with a clear error rather than confusing 404 responses. |
| 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
34 changes: 34 additions & 0 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,35 @@ def is_from_tag_name_defined() -> bool:
value = ActionInputs.get_from_tag_name()
return value.strip() != ""

@staticmethod
def is_from_tag_name_provided() -> bool:
Comment thread
tmikula-dev marked this conversation as resolved.
Outdated
"""
Check whether the from-tag-name input was explicitly provided (even if whitespace-only).

Reads the raw env var before normalization, so a whitespace-only value is still
treated as provided and routed to the fail-fast compare-mode validation in
validate_inputs() rather than silently skipping it.
"""
return get_action_input(FROM_TAG_NAME, default="") != ""

@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.
Outdated
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.
Outdated

@staticmethod
def get_chapters() -> list[dict[str, str]]:
"""
Expand Down Expand Up @@ -669,6 +698,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 ActionInputs.is_from_tag_name_provided():
ActionInputs.validate_compare_mode_tag_names()
Comment thread
tmikula-dev marked this conversation as resolved.
Outdated

# Log errors if any
if errors:
for error in errors:
Expand Down
Empty file.
38 changes: 36 additions & 2 deletions release_notes_generator/data/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from typing import Optional, Callable

import semver
from github import Github
from github import Github, GithubException
from github.GitRelease import GitRelease
from github.Issue import Issue
from github.PullRequest import PullRequest
Expand Down Expand Up @@ -107,7 +107,13 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
)
comparison = self._safe_call(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name())
from_tag = ActionInputs.get_from_tag_name()
to_tag = ActionInputs.get_tag_name()
Comment thread
tmikula-dev marked this conversation as resolved.
Outdated

self._validate_tag_exists(repo, from_tag)
self._validate_tag_exists(repo, to_tag)

comparison = self._safe_call(repo.compare)(from_tag, to_tag)
Comment thread
miroslavpojer marked this conversation as resolved.
if comparison is None:
logger.error(
"Compare API returned no result for '%s'...'%s'. Ending!",
Expand Down Expand Up @@ -157,6 +163,34 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:
len(data.pull_requests),
)

def _validate_tag_exists(self, repo: Repository, tag: str) -> None:
try:
repo.get_git_ref(f"tags/{tag}")
Comment thread
miroslavpojer marked this conversation as resolved.
except GithubException as e:
if e.status == 404:
logger.error(
"Tag '%s' does not exist in repository '%s'. Ending!",
tag,
repo.full_name,
)
else:
logger.error(
"GitHub API error validating tag '%s' in repository '%s' (HTTP %s): %s. Ending!",
tag,
repo.full_name,
e.status,
e.data,
)
sys.exit(1)
except Exception as exc:
logger.error(
"Unexpected error validating tag '%s' in repository '%s': %s. Ending!",
tag,
repo.full_name,
exc,
)
sys.exit(1)

Comment thread
miroslavpojer marked this conversation as resolved.
def _handle_since_time_mode(self, repo: Repository, data: MinedData) -> None:
"""
Handle since-time mode: mine issues, PRs, and commits based on release timestamp.
Expand Down
87 changes: 86 additions & 1 deletion tests/unit/release_notes_generator/data/test_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from datetime import datetime
from typing import Optional

from github import Github
from github import Github, GithubException
from github.Commit import Commit
from github.GitRelease import GitRelease
from github.Issue import Issue
Expand Down Expand Up @@ -587,6 +587,7 @@ def _make_compare_miner(mocker, mock_repo, *, from_tag="v2.6.3", to_tag="v2.6.4"
release_mock.published_at = published_at
release_mock.tag_name = from_tag
mock_repo.get_release.return_value = release_mock
mock_repo.get_git_ref.return_value = mocker.Mock()
mock_repo.get_issues.return_value = []

comparison_mock = mocker.Mock()
Expand Down Expand Up @@ -786,3 +787,87 @@ 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-ref existence validation ---

Comment thread
miroslavpojer marked this conversation as resolved.

def test_mine_data_compare_mode_from_tag_not_found_exits(mocker, mock_repo):
"""from-tag-name absent as git ref → specific error logged and sys.exit(1)."""
not_found = GithubException(404, {"message": "Not Found"}, {})
mock_repo.get_git_ref.side_effect = lambda ref: (_ for _ in ()).throw(not_found) if ref == "tags/v2.6.3" else None
error_mock = mocker.patch("release_notes_generator.data.miner.logger.error")

miner = _make_compare_miner(mocker, mock_repo)

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

assert exc_info.value.code == 1
logged_messages = " ".join(str(call) for call in error_mock.call_args_list)
assert "v2.6.3" in logged_messages
mock_repo.compare.assert_not_called()


def test_mine_data_compare_mode_to_tag_not_found_exits(mocker, mock_repo):
"""tag-name absent as git ref → specific error logged and sys.exit(1)."""
not_found = GithubException(404, {"message": "Not Found"}, {})
mock_repo.get_git_ref.side_effect = lambda ref: (_ for _ in ()).throw(not_found) if ref == "tags/v2.6.4" else None
error_mock = mocker.patch("release_notes_generator.data.miner.logger.error")

miner = _make_compare_miner(mocker, mock_repo)

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

assert exc_info.value.code == 1
logged_messages = " ".join(str(call) for call in error_mock.call_args_list)
assert "v2.6.4" in logged_messages
mock_repo.compare.assert_not_called()


def test_mine_data_compare_mode_from_tag_api_error_exits(mocker, mock_repo):
"""Non-404 GitHub error on from-tag validation → API failure logged and sys.exit(1)."""
api_error = GithubException(503, {"message": "Service Unavailable"}, {})
mock_repo.get_git_ref.side_effect = api_error
error_mock = mocker.patch("release_notes_generator.data.miner.logger.error")

miner = _make_compare_miner(mocker, mock_repo)

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

assert exc_info.value.code == 1
logged_messages = " ".join(str(call) for call in error_mock.call_args_list)
assert "503" in logged_messages
mock_repo.compare.assert_not_called()


def test_mine_data_compare_mode_both_tags_exist_calls_compare(mocker, mock_repo):
"""Both tags exist as git refs → repo.compare() is called normally."""
mock_repo.get_git_ref.return_value = None # no exception = tag exists

commit_mock = mocker.Mock()
commit_mock.sha = "abc123"
commit_mock.commit.message = "Fix something (#1)"

miner = _make_compare_miner(mocker, mock_repo, compare_commits=[commit_mock])
miner.mine_data()

mock_repo.compare.assert_called_once_with("v2.6.3", "v2.6.4")


def test_mine_data_compare_mode_network_error_on_tag_validation_exits(mocker, mock_repo):
"""Network-layer exception (non-GithubException) on tag ref lookup → error logged and sys.exit(1)."""
mock_repo.get_git_ref.side_effect = OSError("Connection timed out")
error_mock = mocker.patch("release_notes_generator.data.miner.logger.error")

miner = _make_compare_miner(mocker, mock_repo)

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

assert exc_info.value.code == 1
logged_messages = " ".join(str(call) for call in error_mock.call_args_list)
assert "Connection timed out" in logged_messages
mock_repo.compare.assert_not_called()
69 changes: 69 additions & 0 deletions tests/unit/release_notes_generator/test_action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,33 @@ def test_get_from_tag_name_empty(mocker):
assert ActionInputs.get_from_tag_name() == ""


# --- is_from_tag_name_provided ---


def test_is_from_tag_name_provided_when_env_var_set(monkeypatch):
"""Env var present with a valid tag → True."""
monkeypatch.setenv("INPUT_FROM_TAG_NAME", "v1.0.0")
assert ActionInputs.is_from_tag_name_provided() is True


def test_is_from_tag_name_provided_when_env_var_whitespace_only(monkeypatch):
"""Env var present but whitespace-only → True (user provided a value, even if invalid)."""
monkeypatch.setenv("INPUT_FROM_TAG_NAME", " ")
assert ActionInputs.is_from_tag_name_provided() is True


def test_is_from_tag_name_provided_when_env_var_empty(monkeypatch):
"""Env var empty string (action.yml default) → False (compare mode not requested)."""
monkeypatch.setenv("INPUT_FROM_TAG_NAME", "")
assert ActionInputs.is_from_tag_name_provided() is False


def test_is_from_tag_name_provided_when_env_var_absent(monkeypatch):
"""Env var absent → False."""
monkeypatch.delenv("INPUT_FROM_TAG_NAME", raising=False)
assert ActionInputs.is_from_tag_name_provided() is False


def test_get_from_tag_name_invalid_format(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.2.beta")
with pytest.raises(ValueError) as excinfo:
Expand All @@ -185,6 +212,48 @@ 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")
mock_error = mocker.patch("release_notes_generator.action_inputs.logger.error")

ActionInputs.validate_compare_mode_tag_names()

mock_exit.assert_not_called()
mock_error.assert_not_called()


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