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

---

## Fallback Behavior — Compare-Endpoint 404 / Eventual Consistency

In CI/CD pipelines it is common to call the release notes generator immediately after pushing a new tag.
Due to eventual consistency, GitHub's compare endpoint may return a 404 even for a tag that was just pushed
and is technically resolvable via the commits API.

**Fallback strategy:**

If the compare API fails with a 404, the action attempts to resolve the target ref via `get_commit()`:
1. The action falls back to fetching the latest commit SHA of the target tag/ref via the commits API
2. A warning is logged to indicate the fallback occurred
3. Processing continues with just that single commit as the comparison baseline
4. If the ref still cannot be resolved, the action exits with a non-zero code

For non-404 errors (auth failures, rate limits, server errors) the action logs the error and exits immediately.

This scenario is handled gracefully 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:**

```log
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 returned 404 for 'v2.6.3'...'v2.6.4'. Attempting to resolve target ref 'v2.6.4' via get_commit().
```
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved

---

## Data Flow

```
Expand All @@ -109,8 +150,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
64 changes: 56 additions & 8 deletions release_notes_generator/data/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import semver
from github import Github
from github.GitRelease import GitRelease
from github import GithubException
from github.Issue import Issue
from github.PullRequest import PullRequest
from github.Repository import Repository
Expand All @@ -49,6 +50,23 @@
logger = logging.getLogger(__name__)


class _MinimalComparison: # pylint: disable=too-few-public-methods
"""Fallback comparison object when compare API fails (e.g., 404).

Used when repo.compare() cannot resolve a comparison (e.g., compare endpoint
transient failure); provides a minimal interface compatible with
github.Comparison.Comparison by wrapping a single resolved commit.
"""

def __init__(self, commit: GithubCommit) -> None:
"""Initialize with a single commit.

Parameters:
commit: The commit to use as the fallback baseline.
"""
self.commits: list[GithubCommit] = [commit]


class DataMiner:
"""
Class responsible for mining data from GitHub.
Expand Down Expand Up @@ -99,6 +117,11 @@ 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 with 404 (e.g. eventual-consistency: target ref not yet visible),
attempt to fall back by resolving the target ref via get_commit().
- If compare fails with other errors (auth, rate-limit, transient), log and exit.
- If fallback resolve succeeds, use the resolved commit as baseline.
- If fallback resolve also fails, log and exit.
- Extract PR numbers from commit messages and fetch those PRs.
- Filter out commits that already have a PR reference to avoid duplication.
"""
Expand All @@ -107,15 +130,40 @@ 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())
comparison = None
try:
comparison = repo.compare(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name())
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
except GithubException as e:
if e.status == 404:
Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
logger.warning(
"Compare API returned 404 for '%s'...'%s'. "
"Attempting to resolve target ref '%s' via get_commit().",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
ActionInputs.get_tag_name(),
)
else:
logger.error(
"Compare API failed for '%s'...'%s' with status %d: %s",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
e.status,
e.data.get("message", str(e)) if isinstance(e.data, dict) else str(e),
)
sys.exit(1)

Comment thread
miroslavpojer marked this conversation as resolved.
Resolved
if comparison is None:
logger.error(
"Compare API returned no result for '%s'...'%s'. Ending!",
ActionInputs.get_from_tag_name(),
ActionInputs.get_tag_name(),
)
sys.exit(1)
compare_commits: list[GithubCommit] = list(comparison.commits)
# 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:
logger.error(
"Could not retrieve commit for '%s'. Ending!",
ActionInputs.get_tag_name(),
)
sys.exit(1)
# Create a minimal comparison object with just the target commit
comparison = _MinimalComparison(target_commit) # type: ignore[assignment]
compare_commits: list[GithubCommit] = list(comparison.commits) # type: ignore[union-attr]
total_commits = getattr(comparison, "total_commits", None)
if isinstance(total_commits, int) and total_commits > len(compare_commits):
logger.warning(
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/release_notes_generator/data/test_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from typing import Optional

from github import Github
from github import GithubException
from github.Commit import Commit
from github.GitRelease import GitRelease
from github.Issue import Issue
Expand Down Expand Up @@ -746,6 +747,119 @@ 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 raises 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 (no PR ref)"

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 raises 404 exception
mock_repo.compare.side_effect = GithubException(404, {"message": "Comparison failed"})
# Fall back to fetching the target commit
mock_repo.get_commit.return_value = target_commit
mock_repo.get_issues.return_value = []

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 with expected message
warning_mock.assert_called_once()
call_args = warning_mock.call_args[0]
assert "Compare API returned 404" in call_args[0]
assert "Attempting to resolve target ref" in call_args[0]

# Verify the fallback actually queried the target ref via get_commit()
mock_repo.get_commit.assert_called_once_with("v2.6.4")

# Verify the fallback commit was used
assert "targetsha123" in data.compare_commit_shas, "Target commit SHA must be in compare_commit_shas"
# Commit is included since message has no PR reference, so no filtering
assert len(data.commits) == 1, "Fallback commit should be included in data.commits"


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.side_effect = GithubException(404, {"message": "Not Found"})
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


def test_mine_data_compare_mode_exits_on_non_404_github_exception(mocker, mock_repo):
"""Non-404 GithubException from compare API must exit immediately without falling back."""
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

mock_repo.compare.side_effect = GithubException(500, {"message": "Internal Server Error"})
mock_repo.get_issues.return_value = []

error_mock = mocker.patch("release_notes_generator.data.miner.logger.error")

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

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

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

# Fallback (get_commit) must NOT have been attempted
mock_repo.get_commit.assert_not_called()
# Error must have been logged with the non-404 status
assert any("500" in str(call) for call in error_mock.call_args_list)


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


Expand Down