Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
47 changes: 45 additions & 2 deletions docs/features/compare_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,43 @@ Issues are always filtered by timestamp regardless of mode.

---

## Fallback Behavior — When the Target Tag Doesn't Exist Yet

In CI/CD pipelines, it's common to call the release notes generator *before* the target tag is created.
When the compare API receives a request for a non-existent tag, it returns a 404 error.

Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
**Fallback strategy:**

If the compare API fails with a 404 or any other error:
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
1. The action falls back to fetching the latest commit SHA of the target tag/ref
2. A warning is logged to indicate the fallback occurred
3. Processing continues with just that single commit as the comparison baseline

This ensures the action completes gracefully even when tags are created in a sequence:

```yaml
- name: Build & Tag Release
run: |
./scripts/build.sh
git tag v2.6.4
git push origin v2.6.4

- name: Generate Release Notes # Runs immediately after tagging
uses: AbsaOSS/generate-release-notes@v1
with:
tag-name: v2.6.4
from-tag-name: v2.6.3
```

**Log output when fallback is triggered:**

```
2026-06-25 12:22:27 - INFO - Compare mode: using repo.compare('v2.6.3', 'v2.6.4').
2026-06-25 12:22:27 - WARNING - Compare API failed for 'v2.6.3'...'v2.6.4' (target tag may not exist yet). Falling back to the latest commit SHA of 'v2.6.4'.
Comment thread
Copilot marked this conversation as resolved.
Resolved
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved

---

## Data Flow

```
Expand All @@ -109,8 +146,14 @@ from-tag-name provided?
GitHub Compare API: get_commits(since=data.since)
commits unique to to-tag get_pulls(state=closed)
│ │
extract PR numbers FilterByRelease drops
from commit messages PRs/commits before since
API fails (404)? FilterByRelease drops
│ PRs/commits before since
├─ YES: Fallback to
│ target ref SHA
│ (log warning)
│
extract PR numbers
from commit messages
│
fetch each PR by number
│
Expand Down
21 changes: 18 additions & 3 deletions release_notes_generator/data/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:

Logic:
- Fetch commits between from_tag and to_tag using repo.compare().
- If compare fails (404), fall back to the latest commit SHA of to_tag.
- Extract PR numbers from commit messages and fetch those PRs.
- Filter out commits that already have a PR reference to avoid duplication.
"""
Expand All @@ -109,12 +110,26 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None:
)
comparison = self._safe_call(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name())
if comparison is None:
logger.error(
"Compare API returned no result for '%s'...'%s'. Ending!",
logger.warning(
"Compare API failed for '%s'...'%s' (target tag may not exist yet). "
"Falling back to the latest commit SHA of '%s'.",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
ActionInputs.get_tag_name(),
)
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
sys.exit(1)
# Fall back: fetch the latest commit of the target tag/ref
target_commit = self._safe_call(repo.get_commit)(ActionInputs.get_tag_name())
if target_commit is None:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
logger.error(
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
"Could not retrieve commit for '%s'. Ending!",
ActionInputs.get_tag_name(),
)
sys.exit(1)
# Create a minimal comparison object with just the target commit
class MinimalComparison:
def __init__(self, commit):
self.commits = [commit]
comparison = MinimalComparison(target_commit)
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
compare_commits: list[GithubCommit] = list(comparison.commits)
total_commits = getattr(comparison, "total_commits", None)
if isinstance(total_commits, int) and total_commits > len(compare_commits):
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/release_notes_generator/data/test_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,82 @@ def test_mine_data_compare_mode_warns_on_retrieval_cap_without_total(mocker, moc
)


def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo):
"""Test that when compare API returns None (404), fallback to target tag's latest commit SHA."""
target_commit = mocker.Mock()
target_commit.sha = "targetsha123"
target_commit.commit.message = "Latest commit on target tag (#99)"

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="v2.6.3")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_tag_name", return_value="v2.6.4")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_github_repository", return_value="org/repo")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_published_at", return_value=False)

release_mock = mocker.Mock(spec=GitRelease)
release_mock.created_at = datetime(2026, 5, 7)
release_mock.published_at = None
release_mock.tag_name = "v2.6.3"
mock_repo.get_release.return_value = release_mock

# Compare API returns None (404)
mock_repo.compare.return_value = None
# Fall back to fetching the target commit
mock_repo.get_commit.return_value = target_commit
mock_repo.get_issues.return_value = []
mock_repo.get_pull.return_value = mocker.Mock(spec=PullRequest)

warning_mock = mocker.patch("release_notes_generator.data.miner.logger.warning")

github_mock = mocker.Mock(spec=Github)
github_mock.get_repo.return_value = mock_repo

decorator_mock = lambda f: f
miner = DataMiner(github_mock, mocker.Mock())
miner._safe_call = decorator_mock
data = miner.mine_data()
Comment thread
Copilot marked this conversation as resolved.
Resolved

Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
# Verify warning was logged
warning_mock.assert_called_once()
call_args = warning_mock.call_args[0]
assert "Compare API failed" in call_args[0]
assert "Falling back to the latest commit SHA" in call_args[0]

# Verify the target commit was used
assert "targetsha123" in data.compare_commit_shas
assert len(data.commits) <= 1 # May be 0 if PR number found and filtered


def test_mine_data_compare_mode_exits_when_fallback_fails(mocker, mock_repo):
"""Test that action exits when both compare API and fallback commit fetch fail."""
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="v2.6.3")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_tag_name", return_value="v2.6.4")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_github_repository", return_value="org/repo")
mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_published_at", return_value=False)

release_mock = mocker.Mock(spec=GitRelease)
release_mock.created_at = datetime(2026, 5, 7)
release_mock.published_at = None
release_mock.tag_name = "v2.6.3"
mock_repo.get_release.return_value = release_mock

# Both compare and get_commit fail
mock_repo.compare.return_value = None
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
mock_repo.get_commit.return_value = None
mock_repo.get_issues.return_value = []

github_mock = mocker.Mock(spec=Github)
github_mock.get_repo.return_value = mock_repo

decorator_mock = lambda f: f
miner = DataMiner(github_mock, mocker.Mock())
miner._safe_call = decorator_mock

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


# --- mine_data timestamp mode (regression) ---


Expand Down
Loading