From 7488bef6e45e1219b06ceb228b446c20d3c38173 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 10:47:53 +0200 Subject: [PATCH 01/11] feat: implement fallback behavior for compare API 404 errors in DataMiner --- docs/features/compare_mode.md | 47 +++++++++++- release_notes_generator/data/miner.py | 21 ++++- .../data/test_miner.py | 76 +++++++++++++++++++ 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/docs/features/compare_mode.md b/docs/features/compare_mode.md index bdf9efc4..2599370c 100644 --- a/docs/features/compare_mode.md +++ b/docs/features/compare_mode.md @@ -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. + +**Fallback strategy:** + +If the compare API fails with a 404 or any other error: +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'. +``` + +--- + ## Data Flow ``` @@ -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 │ diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index dc32e271..0e441909 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -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. """ @@ -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(), ) - 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: + 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 + class MinimalComparison: + def __init__(self, commit): + self.commits = [commit] + comparison = MinimalComparison(target_commit) 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): diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index 2c6a3554..4c49af3f 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -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() + + # 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 + 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() + + # --- mine_data timestamp mode (regression) --- From 9f5c32ba81a95196032a0773810d3ec803cb15e1 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 11:31:59 +0200 Subject: [PATCH 02/11] feat: enhance fallback mechanism for compare API 404 errors in DataMiner --- docs/features/compare_mode.md | 2 +- release_notes_generator/data/miner.py | 57 ++++++++++++++----- .../data/test_miner.py | 12 ++-- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/features/compare_mode.md b/docs/features/compare_mode.md index 2599370c..945d24c4 100644 --- a/docs/features/compare_mode.md +++ b/docs/features/compare_mode.md @@ -128,7 +128,7 @@ This ensures the action completes gracefully even when tags are created in a seq **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 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'. ``` diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 0e441909..38c2e78b 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -28,6 +28,7 @@ import semver from github import Github from github.GitRelease import GitRelease +from github.GithubException import GithubException from github.Issue import Issue from github.PullRequest import PullRequest from github.Repository import Repository @@ -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. @@ -99,7 +117,9 @@ 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. + - If compare fails with 404, attempt to fall back by resolving the target ref via get_commit(). + - If compare fails with other errors (auth, rate-limit, transient), propagate the error. + - If fallback resolve fails or succeeds, use resolved commit as baseline. - Extract PR numbers from commit messages and fetch those PRs. - Filter out commits that already have a PR reference to avoid duplication. """ @@ -108,15 +128,29 @@ 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()) + except GithubException as e: + if e.status == 404: + 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), + ) + raise + if comparison is None: - 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(), - ) # 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: @@ -126,10 +160,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: ) 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) + comparison = _MinimalComparison(target_commit) 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): diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index 4c49af3f..19f3bdf5 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -750,7 +750,7 @@ 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)" + 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") @@ -769,7 +769,6 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) # 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") @@ -781,15 +780,16 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) miner._safe_call = decorator_mock data = miner.mine_data() - # Verify warning was logged + # Verify warning was logged with expected message 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 + # 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): From c9ee01b2f5fb901b7263a10da981f38cb217a078 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 11:43:43 +0200 Subject: [PATCH 03/11] fix: handle 404 errors in compare mode by falling back to target tag's latest commit SHA --- release_notes_generator/data/miner.py | 6 +++--- tests/unit/release_notes_generator/data/test_miner.py | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 38c2e78b..9d1b14d0 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -149,7 +149,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: e.data.get("message", str(e)) if isinstance(e.data, dict) else str(e), ) raise - + if comparison is None: # Fall back: fetch the latest commit of the target tag/ref target_commit = self._safe_call(repo.get_commit)(ActionInputs.get_tag_name()) @@ -160,8 +160,8 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: ) sys.exit(1) # Create a minimal comparison object with just the target commit - comparison = _MinimalComparison(target_commit) - compare_commits: list[GithubCommit] = list(comparison.commits) + 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( diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index 19f3bdf5..c5f4563a 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -23,6 +23,7 @@ from github import Github from github.Commit import Commit from github.GitRelease import GitRelease +from github.GithubException import GithubException from github.Issue import Issue from github.PullRequest import PullRequest from github.Repository import Repository @@ -747,7 +748,7 @@ 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.""" + """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)" @@ -764,8 +765,8 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) 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 + # 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 = [] @@ -783,8 +784,8 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) # Verify warning was logged with expected message 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] + assert "Compare API returned 404" in call_args[0] + assert "Attempting to resolve target ref" in call_args[0] # Verify the fallback commit was used assert "targetsha123" in data.compare_commit_shas, "Target commit SHA must be in compare_commit_shas" From 1ba25b47a8a238519a36084ed857213e61cf5516 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 11:53:56 +0200 Subject: [PATCH 04/11] fix: improve fallback behavior for compare API 404 errors in DataMiner --- docs/features/compare_mode.md | 18 +++++++++++------- release_notes_generator/data/miner.py | 14 ++++++++------ .../release_notes_generator/data/test_miner.py | 7 +++++-- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/features/compare_mode.md b/docs/features/compare_mode.md index 945d24c4..62ec5700 100644 --- a/docs/features/compare_mode.md +++ b/docs/features/compare_mode.md @@ -98,19 +98,23 @@ Issues are always filtered by timestamp regardless of mode. --- -## Fallback Behavior — When the Target Tag Doesn't Exist Yet +## Fallback Behavior — Compare-Endpoint 404 / Eventual Consistency -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. +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 or any other error: -1. The action falls back to fetching the latest commit SHA of the target tag/ref +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 -This ensures the action completes gracefully even when tags are created in a sequence: +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 @@ -130,7 +134,7 @@ This ensures the action completes gracefully even when tags are created in a seq ```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 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'. +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(). ``` --- diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 9d1b14d0..70ddb7b0 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -28,7 +28,7 @@ import semver from github import Github from github.GitRelease import GitRelease -from github.GithubException import GithubException +from github import GithubException from github.Issue import Issue from github.PullRequest import PullRequest from github.Repository import Repository @@ -117,9 +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, attempt to fall back by resolving the target ref via get_commit(). - - If compare fails with other errors (auth, rate-limit, transient), propagate the error. - - If fallback resolve fails or succeeds, use resolved commit as baseline. + - 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. """ @@ -130,7 +132,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: ) comparison = None try: - comparison = repo.compare(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) + comparison = self._safe_call(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) except GithubException as e: if e.status == 404: logger.warning( @@ -148,7 +150,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: e.status, e.data.get("message", str(e)) if isinstance(e.data, dict) else str(e), ) - raise + sys.exit(1) if comparison is None: # Fall back: fetch the latest commit of the target tag/ref diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index c5f4563a..bdc18883 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -21,9 +21,9 @@ from typing import Optional from github import Github +from github import GithubException from github.Commit import Commit from github.GitRelease import GitRelease -from github.GithubException import GithubException from github.Issue import Issue from github.PullRequest import PullRequest from github.Repository import Repository @@ -787,6 +787,9 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) 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 @@ -808,7 +811,7 @@ def test_mine_data_compare_mode_exits_when_fallback_fails(mocker, mock_repo): mock_repo.get_release.return_value = release_mock # Both compare and get_commit fail - mock_repo.compare.return_value = None + mock_repo.compare.side_effect = GithubException(404, {"message": "Not Found"}) mock_repo.get_commit.return_value = None mock_repo.get_issues.return_value = [] From cfff4fb53b1b49e2fd485716245f178903c8076e Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 12:01:52 +0200 Subject: [PATCH 05/11] fix: handle non-404 GithubException in compare mode to exit immediately --- release_notes_generator/data/miner.py | 2 +- .../data/test_miner.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 70ddb7b0..97175caa 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -132,7 +132,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: ) comparison = None try: - comparison = self._safe_call(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) + comparison = repo.compare(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) except GithubException as e: if e.status == 404: logger.warning( diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index bdc18883..0bbd8659 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -826,6 +826,40 @@ def test_mine_data_compare_mode_exits_when_fallback_fails(mocker, mock_repo): miner.mine_data() +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 + + 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) --- From b5d0a96a1da850cc291ced5802c89b197f0dbb2e Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 12:10:05 +0200 Subject: [PATCH 06/11] fix: handle network errors in compare mode to prevent crashes --- release_notes_generator/data/miner.py | 9 +++++++++ tests/unit/release_notes_generator/data/test_miner.py | 2 -- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 97175caa..22d19068 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -30,6 +30,7 @@ from github.GitRelease import GitRelease from github import GithubException from github.Issue import Issue +from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout, RequestException from github.PullRequest import PullRequest from github.Repository import Repository from github.Commit import Commit as GithubCommit @@ -133,6 +134,14 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: comparison = None try: comparison = repo.compare(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) + except (RequestsConnectionError, Timeout, RequestException) as e: + logger.error( + "Network error during compare API call for '%s'...'%s': %s", + ActionInputs.get_from_tag_name(), + ActionInputs.get_tag_name(), + e, + ) + sys.exit(1) except GithubException as e: if e.status == 404: logger.warning( diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index 0bbd8659..e8199e6f 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -776,7 +776,6 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) 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() @@ -818,7 +817,6 @@ def test_mine_data_compare_mode_exits_when_fallback_fails(mocker, mock_repo): 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 From 47d0b37efd0beba35a9c87d7f1a762a57b5a53eb Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 12:18:07 +0200 Subject: [PATCH 07/11] fix: integrate rate limiter into compare mode to handle API calls --- release_notes_generator/data/miner.py | 3 ++- .../release_notes_generator/data/test_miner.py | 18 +++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 22d19068..9dfa155c 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -75,6 +75,7 @@ class DataMiner: def __init__(self, github_instance: Github, rate_limiter: GithubRateLimiter): self.github_instance = github_instance + self._rate_limiter = rate_limiter self._safe_call = safe_call_decorator(rate_limiter) def mine_data(self) -> MinedData: @@ -133,7 +134,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: ) comparison = None try: - comparison = repo.compare(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) + comparison = self._rate_limiter(repo.compare)(ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name()) except (RequestsConnectionError, Timeout, RequestException) as e: logger.error( "Network error during compare API call for '%s'...'%s': %s", diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index e8199e6f..b972bcea 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -306,7 +306,7 @@ def test_mine_data_commits_without_since(mocker, mock_repo): miner = DataMiner(gh, mocker.Mock()) # Bypass safe_call wrapper - miner._safe_call = lambda f: f + miner._safe_call = decorator_mock # Inputs / release behavior mocker.patch( @@ -333,7 +333,7 @@ def test_scan_sub_issues_for_parents(mocker, mock_repo, mined_data_simple): # miner setup miner = DataMiner(gh, mocker.Mock()) - miner._safe_call = lambda f: f + miner._safe_call = decorator_mock mocker.patch.object(miner, "_make_bulk_sub_issue_collector", return_value=ChildBulkSubIssueCollector()) mocker.patch.object(miner, "_fetch_all_repositories_in_cache", return_value=None) @@ -356,7 +356,7 @@ def test_fetch_all_repositories_in_cache(mocker, mock_repo, mined_data_simple): # miner setup miner = DataMiner(gh, mocker.Mock()) - miner._safe_call = lambda f: f + miner._safe_call = decorator_mock patch_parents_sub_issues: dict[str, list[str]] = {} patch_parents_sub_issues["org_1/another_repo#122"] = ["org_2/another_repo#122", "org_3/another_repo#122", "o/r#1"] @@ -405,7 +405,7 @@ def fake_get_issue(num): # miner setup miner = DataMiner(gh, mocker.Mock()) - miner._safe_call = lambda f: f + miner._safe_call = decorator_mock patch_parents_sub_issues: dict[str, list[str]] = {} patch_parents_sub_issues["org/repo#1"] = [ @@ -455,7 +455,7 @@ def fake_get_issue(num): # miner setup miner = DataMiner(gh, mocker.Mock()) - miner._safe_call = lambda f: f + miner._safe_call = decorator_mock patch_parents_sub_issues: dict[str, list[str]] = {} @@ -478,7 +478,7 @@ def test_fetch_prs_for_fetched_cross_issues(mocker, mock_repo): # Miner with safe_call bypassed gh = mocker.Mock() miner = DataMiner(gh, mocker.Mock()) - miner._safe_call = lambda f: f # no decorator wrapping + miner._safe_call = decorator_mock # PR object returned by as_pull_request() pr_obj = mocker.Mock(spec=PullRequest) @@ -606,6 +606,7 @@ def _make_compare_miner(mocker, mock_repo, *, from_tag="v2.6.3", to_tag="v2.6.4" miner = DataMiner(github_mock, mocker.Mock()) miner._safe_call = decorator_mock + miner._rate_limiter = decorator_mock return miner @@ -778,6 +779,7 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) miner = DataMiner(github_mock, mocker.Mock()) miner._safe_call = decorator_mock + miner._rate_limiter = decorator_mock data = miner.mine_data() # Verify warning was logged with expected message @@ -819,6 +821,7 @@ def test_mine_data_compare_mode_exits_when_fallback_fails(mocker, mock_repo): miner = DataMiner(github_mock, mocker.Mock()) miner._safe_call = decorator_mock + miner._rate_limiter = decorator_mock with pytest.raises(SystemExit): miner.mine_data() @@ -847,7 +850,8 @@ def test_mine_data_compare_mode_exits_on_non_404_github_exception(mocker, mock_r github_mock.get_repo.return_value = mock_repo miner = DataMiner(github_mock, mocker.Mock()) - miner._safe_call = lambda f: f + miner._safe_call = decorator_mock + miner._rate_limiter = decorator_mock with pytest.raises(SystemExit): miner.mine_data() From 88bca0c3c5ad75a5b92ba49e245c5d3ef024e179 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 12:56:58 +0200 Subject: [PATCH 08/11] Imprpoved solution after review. --- release_notes_generator/data/miner.py | 50 ++++------ .../data/test_miner.py | 95 +++++++++++++++---- 2 files changed, 94 insertions(+), 51 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 9dfa155c..07433f13 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -51,23 +51,6 @@ 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. @@ -119,10 +102,10 @@ 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 404 (target tag does not exist yet), + fall back to the latest commit on the default branch. - 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 succeeds, use the latest commit on default branch 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. @@ -146,11 +129,10 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: except GithubException as e: if e.status == 404: logger.warning( - "Compare API returned 404 for '%s'...'%s'. " - "Attempting to resolve target ref '%s' via get_commit().", + "Compare API returned 404 for '%s'...'%s': target tag does not exist. " + "Falling back to latest commit on default branch.", ActionInputs.get_from_tag_name(), ActionInputs.get_tag_name(), - ActionInputs.get_tag_name(), ) else: logger.error( @@ -163,17 +145,25 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: sys.exit(1) if comparison is None: - # Fall back: fetch the latest commit of the target tag/ref - target_commit = self._safe_call(repo.get_commit)(ActionInputs.get_tag_name()) + # Fall back: target tag does not exist; use the latest commit on the default branch + branch = self._safe_call(repo.get_branch)(repo.default_branch) + target_commit = branch.commit if branch is not None else None if target_commit is None: logger.error( - "Could not retrieve commit for '%s'. Ending!", - ActionInputs.get_tag_name(), + "Could not retrieve latest commit on default branch '%s'. Ending!", + repo.default_branch, + ) + sys.exit(1) + # Retry compare using the resolved commit SHA as the target + comparison = self._rate_limiter(repo.compare)(ActionInputs.get_from_tag_name(), target_commit.sha) + if comparison is None: + logger.error( + "Compare API returned no result for '%s'...'%s'. Ending!", + ActionInputs.get_from_tag_name(), + target_commit.sha, ) 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] + 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): logger.warning( diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index b972bcea..e41a0a70 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -749,10 +749,10 @@ 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.""" + """Test that when compare API raises 404, fallback resolves the latest commit on the default branch and retries compare.""" target_commit = mocker.Mock() target_commit.sha = "targetsha123" - target_commit.commit.message = "Latest commit on target tag (no PR ref)" + target_commit.commit.message = "Latest commit on default branch (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") @@ -765,11 +765,21 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) release_mock.published_at = None release_mock.tag_name = "v2.6.3" mock_repo.get_release.return_value = release_mock + mock_repo.default_branch = "main" - # 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 + branch_mock = mocker.Mock() + branch_mock.commit = target_commit + mock_repo.get_branch.return_value = branch_mock + + fallback_comparison = mocker.Mock() + fallback_comparison.commits = [target_commit] + fallback_comparison.total_commits = 1 + + # First compare raises 404; second (with resolved SHA) succeeds + mock_repo.compare.side_effect = [ + GithubException(404, {"message": "Comparison failed"}), + fallback_comparison, + ] mock_repo.get_issues.return_value = [] warning_mock = mocker.patch("release_notes_generator.data.miner.logger.warning") @@ -782,23 +792,25 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) miner._rate_limiter = decorator_mock data = miner.mine_data() - # 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 404 warning was logged + first_warning = warning_mock.call_args_list[0] + assert "Compare API returned 404" in first_warning[0][0] + assert "Falling back to latest commit on default branch" in first_warning[0][0] - # Verify the fallback actually queried the target ref via get_commit() - mock_repo.get_commit.assert_called_once_with("v2.6.4") + # Verify fallback fetched the default branch (not the tag directly) + mock_repo.get_branch.assert_called_once_with("main") - # Verify the fallback commit was used + # Verify second compare call used from_tag and resolved SHA + assert mock_repo.compare.call_count == 2 + mock_repo.compare.assert_called_with("v2.6.3", "targetsha123") + + # Verify the fallback commit was included in the result 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.""" +def test_mine_data_compare_mode_exits_when_fallback_branch_not_found(mocker, mock_repo): + """Test that action exits when compare API raises 404 and get_branch returns None.""" 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") @@ -810,10 +822,51 @@ def test_mine_data_compare_mode_exits_when_fallback_fails(mocker, mock_repo): release_mock.published_at = None release_mock.tag_name = "v2.6.3" mock_repo.get_release.return_value = release_mock + mock_repo.default_branch = "main" - # Both compare and get_commit fail + # compare raises 404; get_branch returns None (branch not resolvable) mock_repo.compare.side_effect = GithubException(404, {"message": "Not Found"}) - mock_repo.get_commit.return_value = None + mock_repo.get_branch.return_value = None + mock_repo.get_issues.return_value = [] + + github_mock = mocker.Mock(spec=Github) + github_mock.get_repo.return_value = mock_repo + + miner = DataMiner(github_mock, mocker.Mock()) + miner._safe_call = decorator_mock + miner._rate_limiter = decorator_mock + + with pytest.raises(SystemExit): + miner.mine_data() + + +def test_mine_data_compare_mode_exits_when_fallback_compare_returns_none(mocker, mock_repo): + """Test that action exits when compare API raises 404 and the fallback compare call returns None.""" + target_commit = mocker.Mock() + target_commit.sha = "targetsha123" + + 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.default_branch = "main" + + branch_mock = mocker.Mock() + branch_mock.commit = target_commit + mock_repo.get_branch.return_value = branch_mock + + # First compare raises 404; second (fallback) returns None + mock_repo.compare.side_effect = [ + GithubException(404, {"message": "Not Found"}), + None, + ] mock_repo.get_issues.return_value = [] github_mock = mocker.Mock(spec=Github) @@ -856,8 +909,8 @@ def test_mine_data_compare_mode_exits_on_non_404_github_exception(mocker, mock_r with pytest.raises(SystemExit): miner.mine_data() - # Fallback (get_commit) must NOT have been attempted - mock_repo.get_commit.assert_not_called() + # Fallback (get_branch) must NOT have been attempted + mock_repo.get_branch.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) From a156928777728ac616d8433b0e74081abfbf1968 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 13:05:19 +0200 Subject: [PATCH 09/11] fix: enhance fallback strategy for compare API 404 errors to use default branch commit --- docs/features/compare_mode.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/features/compare_mode.md b/docs/features/compare_mode.md index 62ec5700..89b8b717 100644 --- a/docs/features/compare_mode.md +++ b/docs/features/compare_mode.md @@ -102,15 +102,15 @@ Issues are always filtered by timestamp regardless of mode. 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. +and is technically resolvable via the 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 +If the compare API fails with a 404, the action performs the following: +1. Resolves the latest commit on the repository's default branch via `get_branch()` +2. Retries the compare operation using the from-tag name and the resolved commit's SHA: `repo.compare(from_tag, commit_sha)` +3. A warning is logged to indicate the fallback occurred +4. If the default branch cannot be resolved or the retry returns no result, 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. @@ -134,7 +134,7 @@ This scenario is handled gracefully when tags are created in a sequence: ```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(). +2026-06-25 12:22:27 - WARNING - Compare API returned 404 for 'v2.6.3'...'v2.6.4': target tag does not exist. Falling back to latest commit on default branch. ``` --- @@ -153,7 +153,9 @@ from-tag-name provided? API fails (404)? FilterByRelease drops │ PRs/commits before since ├─ YES: Fallback to - │ target ref SHA + │ get_branch(default) + │ retry compare with + │ resolved SHA │ (log warning) │ extract PR numbers From 1ff8dab22f1217adb4b309e969bd32ee3e5b558c Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 13:09:03 +0200 Subject: [PATCH 10/11] fix: update fallback strategy in compare mode to resolve target ref directly and improve error handling --- docs/features/compare_mode.md | 9 ++--- release_notes_generator/data/miner.py | 30 ++++++++++++---- .../data/test_miner.py | 34 +++++++++---------- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/docs/features/compare_mode.md b/docs/features/compare_mode.md index 89b8b717..62a008e0 100644 --- a/docs/features/compare_mode.md +++ b/docs/features/compare_mode.md @@ -107,12 +107,13 @@ and is technically resolvable via the API. **Fallback strategy:** If the compare API fails with a 404, the action performs the following: -1. Resolves the latest commit on the repository's default branch via `get_branch()` +1. Resolves the requested target ref (tag/branch/SHA) directly via `repo.get_commit()` 2. Retries the compare operation using the from-tag name and the resolved commit's SHA: `repo.compare(from_tag, commit_sha)` 3. A warning is logged to indicate the fallback occurred -4. If the default branch cannot be resolved or the retry returns no result, the action exits with a non-zero code +4. If the target ref cannot be resolved, the action exits with a non-zero code +5. If the retry compare call fails (network error, GithubException), it is treated the same as the first call: errors are logged and the action exits -For non-404 errors (auth failures, rate limits, server errors) the action logs the error and exits immediately. +For non-404 errors on the initial compare (auth failures, rate limits, server errors) the action logs the error and exits immediately without attempting fallback. This scenario is handled gracefully when tags are created in a sequence: @@ -153,7 +154,7 @@ from-tag-name provided? API fails (404)? FilterByRelease drops │ PRs/commits before since ├─ YES: Fallback to - │ get_branch(default) + │ get_commit(target) │ retry compare with │ resolved SHA │ (log warning) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 07433f13..81450fe3 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -145,17 +145,35 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: sys.exit(1) if comparison is None: - # Fall back: target tag does not exist; use the latest commit on the default branch - branch = self._safe_call(repo.get_branch)(repo.default_branch) - target_commit = branch.commit if branch is not None else None + # Fall back: target tag/ref does not exist yet; resolve it directly + target_commit = self._safe_call(repo.get_commit)(ActionInputs.get_tag_name()) if target_commit is None: logger.error( - "Could not retrieve latest commit on default branch '%s'. Ending!", - repo.default_branch, + "Could not retrieve commit for target ref '%s'. Ending!", + ActionInputs.get_tag_name(), ) sys.exit(1) # Retry compare using the resolved commit SHA as the target - comparison = self._rate_limiter(repo.compare)(ActionInputs.get_from_tag_name(), target_commit.sha) + try: + comparison = self._rate_limiter(repo.compare)(ActionInputs.get_from_tag_name(), target_commit.sha) + except (RequestsConnectionError, Timeout, RequestException) as e: + logger.error( + "Network error during retry compare API call for '%s'...'%s': %s", + ActionInputs.get_from_tag_name(), + target_commit.sha, + e, + ) + sys.exit(1) + except GithubException as e: + logger.error( + "Retry compare API failed for '%s'...'%s' with status %d: %s", + ActionInputs.get_from_tag_name(), + target_commit.sha, + e.status, + e.data.get("message", str(e)) if isinstance(e.data, dict) else str(e), + ) + sys.exit(1) + if comparison is None: logger.error( "Compare API returned no result for '%s'...'%s'. Ending!", diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index e41a0a70..0fd95768 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -749,10 +749,10 @@ 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 resolves the latest commit on the default branch and retries compare.""" + """Test that when compare API raises 404, fallback resolves the target ref and retries compare.""" target_commit = mocker.Mock() target_commit.sha = "targetsha123" - target_commit.commit.message = "Latest commit on default branch (no PR ref)" + target_commit.commit.message = "Latest commit on target ref (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") @@ -765,11 +765,8 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) release_mock.published_at = None release_mock.tag_name = "v2.6.3" mock_repo.get_release.return_value = release_mock - mock_repo.default_branch = "main" - branch_mock = mocker.Mock() - branch_mock.commit = target_commit - mock_repo.get_branch.return_value = branch_mock + mock_repo.get_commit.return_value = target_commit fallback_comparison = mocker.Mock() fallback_comparison.commits = [target_commit] @@ -797,8 +794,8 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) assert "Compare API returned 404" in first_warning[0][0] assert "Falling back to latest commit on default branch" in first_warning[0][0] - # Verify fallback fetched the default branch (not the tag directly) - mock_repo.get_branch.assert_called_once_with("main") + # Verify fallback resolved the target ref via get_commit + mock_repo.get_commit.assert_called_once_with("v2.6.4") # Verify second compare call used from_tag and resolved SHA assert mock_repo.compare.call_count == 2 @@ -807,10 +804,15 @@ def test_mine_data_compare_mode_fallback_to_target_sha_on_404(mocker, mock_repo) # Verify the fallback commit was included in the result assert "targetsha123" in data.compare_commit_shas, "Target commit SHA must be in compare_commit_shas" assert len(data.commits) == 1, "Fallback commit should be included in data.commits" + mock_repo.compare.assert_called_with("v2.6.3", "targetsha123") + + # Verify the fallback commit was included in the result + assert "targetsha123" in data.compare_commit_shas, "Target commit SHA must be in compare_commit_shas" + assert len(data.commits) == 1, "Fallback commit should be included in data.commits" def test_mine_data_compare_mode_exits_when_fallback_branch_not_found(mocker, mock_repo): - """Test that action exits when compare API raises 404 and get_branch returns None.""" + """Test that action exits when compare API raises 404 and get_commit returns None.""" 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") @@ -822,11 +824,10 @@ def test_mine_data_compare_mode_exits_when_fallback_branch_not_found(mocker, moc release_mock.published_at = None release_mock.tag_name = "v2.6.3" mock_repo.get_release.return_value = release_mock - mock_repo.default_branch = "main" - # compare raises 404; get_branch returns None (branch not resolvable) + # compare raises 404; get_commit returns None (ref not resolvable) mock_repo.compare.side_effect = GithubException(404, {"message": "Not Found"}) - mock_repo.get_branch.return_value = None + mock_repo.get_commit.return_value = None mock_repo.get_issues.return_value = [] github_mock = mocker.Mock(spec=Github) @@ -856,11 +857,8 @@ def test_mine_data_compare_mode_exits_when_fallback_compare_returns_none(mocker, release_mock.published_at = None release_mock.tag_name = "v2.6.3" mock_repo.get_release.return_value = release_mock - mock_repo.default_branch = "main" - branch_mock = mocker.Mock() - branch_mock.commit = target_commit - mock_repo.get_branch.return_value = branch_mock + mock_repo.get_commit.return_value = target_commit # First compare raises 404; second (fallback) returns None mock_repo.compare.side_effect = [ @@ -909,8 +907,8 @@ def test_mine_data_compare_mode_exits_on_non_404_github_exception(mocker, mock_r with pytest.raises(SystemExit): miner.mine_data() - # Fallback (get_branch) must NOT have been attempted - mock_repo.get_branch.assert_not_called() + # 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) From 7d429e0f212372de1f524c10411d3150219b1ef1 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 26 Jun 2026 13:15:48 +0200 Subject: [PATCH 11/11] Manual test change. --- .github/workflows/release_draft.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release_draft.yml b/.github/workflows/release_draft.yml index 44a43482..2561787c 100644 --- a/.github/workflows/release_draft.yml +++ b/.github/workflows/release_draft.yml @@ -60,7 +60,7 @@ jobs: - name: Generate Release Notes id: generate_release_notes - uses: AbsaOSS/generate-release-notes@B90223510d1704301a36a36f0d86a72a0e72f0cf # v1.0.0 + uses: AbsaOSS/generate-release-notes@bugfix/322-Compare-Mode-Fails-with-404-When-Target-Tag-Not-Yet-Created env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: