From 66cce8a59f7efa88d7c735fcfb91b6e91237e458 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 24 Aug 2026 11:25:19 +0200 Subject: [PATCH 1/5] fix: exclude sync-merge commits from being misclassified as direct commits --- release_notes_generator/data/miner.py | 12 +++++- .../record/factory/default_record_factory.py | 11 ++++- tests/integration/conftest.py | 1 + tests/unit/conftest.py | 12 +++++- .../data/test_miner.py | 35 ++++++++++++++- .../factory/test_default_record_factory.py | 43 +++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index f3f236b1..a836913f 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -136,18 +136,26 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: data.commits = {c: data.home_repository for c in compare_commits} pr_numbers = self._extract_pr_numbers_from_commits(compare_commits) pulls: dict[PullRequest, Repository] = {} + pr_commit_shas: set[str] = set() for number in sorted(pr_numbers): pr = self._safe_call(repo.get_pull)(number) if pr is not None: # Store each PR with its source repository for downstream filtering and processing. # In compare mode, all PRs come from home_repository; cross-repo is handled elsewhere. pulls[pr] = data.home_repository + # dev note: pull.get_commits() returns all commits GitHub associates with the PR, + # including sync-merge commits (base branch merged back into the PR branch) whose + # messages don't match _PR_NUMBER_RE. Excluding them by SHA (rather than by message + # pattern) prevents them being misclassified as stand-alone "direct commits". + pr_commit_shas.update(c.sha for c in self._safe_call(pr.get_commits)() or []) data.pull_requests = pulls - # Only include commits that don't have a PR reference - # (commits identified by PR are redundant with the PR itself) + # Only include commits that aren't already accounted for by a PR + # (commits identified by PR, or belonging to a PR's commit list, are redundant with the PR itself) commits_without_pr: dict[GithubCommit, Repository] = {} for commit in compare_commits: + if commit.sha in pr_commit_shas: + continue subject = commit.commit.message.splitlines()[0] if commit.commit.message else "" has_pr_ref = bool(_PR_NUMBER_RE.search(subject)) if not has_pr_ref: diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index ce8c8835..0e3ae087 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -120,13 +120,20 @@ def _create_record_for_issue(self, issue: Issue, iid: str, issue_labels: Optiona self._records[iid] = IssueRecord(issue=issue, skip=skip_record, issue_labels=issue_labels) self.__registered_issues.add(iid) - # pylint: disable=too-many-statements + # pylint: disable=too-many-statements,too-many-locals def _register_pull_and_its_commits_to_issue( self, pull: PullRequest, pid: str, data: MinedData, target_repository: Optional[Repository] = None ) -> None: pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) - related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] + + # dev note: pull.get_commits() returns all commits GitHub associates with the PR, including + # sync-merge commits (base branch merged back into the PR branch). Without this, such commits + # fall through and get misclassified as stand-alone "direct commits". + pr_commit_shas: set[str] = {c.sha for c in self._safe_call(pull.get_commits)() or []} + if pull.merge_commit_sha: + pr_commit_shas.add(pull.merge_commit_sha) + related_commits = [c for c in data.commits if c.sha in pr_commit_shas] self.__registered_commits.update(c.sha for c in related_commits) pr_repo = target_repository if target_repository is not None else data.home_repository diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3b47ae6b..f27ded94 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -161,6 +161,7 @@ def _factory( pr.user = user pr.assignees = [] pr.get_labels = mocker.Mock(return_value=[make_label(lbl) for lbl in (labels or [])]) + pr.get_commits = mocker.Mock(return_value=[]) return pr return _factory diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 8ec9b3d0..647b239f 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -58,7 +58,7 @@ def __init__(self, full_name): def mock_safe_call_decorator(_rate_limiter): def wrapper(fn): - if fn.__name__ == "get_issues_for_pr": + if getattr(fn, "__name__", None) == "get_issues_for_pr": return mock_get_issues_for_pr return fn @@ -567,6 +567,7 @@ def mock_pull_closed(mocker, mock_user): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -590,6 +591,7 @@ def mock_pull_closed_with_skip_label(mocker): label2 = mocker.Mock(spec=MockLabel) label2.name = "another-skip-label" pull.get_labels.return_value = [label1, label2] + pull.get_commits.return_value = [] return pull @@ -618,6 +620,7 @@ def mock_pull_closed_with_rls_notes_101(mocker, mock_user): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -646,6 +649,7 @@ def mock_pull_closed_with_rls_notes_102(mocker, mock_user): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -667,6 +671,7 @@ def mock_pull_merged_with_rls_notes_101(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -688,6 +693,7 @@ def mock_pull_merged_with_rls_notes_102(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -715,6 +721,7 @@ def mock_pull_merged(mocker, mock_user): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -742,6 +749,7 @@ def mock_pull_open(mocker, mock_user): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -757,6 +765,7 @@ def mock_pull_no_rls_notes(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.get_labels.return_value = [label1] + pull.get_commits.return_value = [] return pull @@ -1292,6 +1301,7 @@ def make_minimal_pr(mocker: MockerFixture, number: int) -> PullRequest: pr.user = None pr.assignees = [] pr.get_labels.return_value = [] + pr.get_commits.return_value = [] return pr diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index a7007689..d684216a 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -599,7 +599,9 @@ def _make_compare_miner(mocker, mock_repo, *, from_tag="v2.6.3", to_tag="v2.6.4" if get_pull_side_effect is not None: mock_repo.get_pull.side_effect = get_pull_side_effect else: - mock_repo.get_pull.return_value = mocker.Mock(spec=PullRequest) + default_pr = mocker.Mock(spec=PullRequest) + default_pr.get_commits.return_value = [] + mock_repo.get_pull.return_value = default_pr github_mock = mocker.Mock(spec=Github) github_mock.get_repo.return_value = mock_repo @@ -628,6 +630,7 @@ def test_mine_data_compare_mode_fetches_prs_by_number(mocker, mock_repo): commit_mock.commit.message = "Fix service access role (#42)" pr_mock = mocker.Mock(spec=PullRequest) pr_mock.number = 42 + pr_mock.get_commits.return_value = [] miner = _make_compare_miner(mocker, mock_repo, compare_commits=[commit_mock], get_pull_side_effect=lambda n: pr_mock if n == 42 else None) @@ -645,8 +648,10 @@ def test_mine_data_compare_mode_multiple_prs(mocker, mock_repo): c2.commit.message = "Fix B (#20)" pr10 = mocker.Mock(spec=PullRequest) pr10.number = 10 + pr10.get_commits.return_value = [] pr20 = mocker.Mock(spec=PullRequest) pr20.number = 20 + pr20.get_commits.return_value = [] miner = _make_compare_miner(mocker, mock_repo, compare_commits=[c1, c2], get_pull_side_effect=lambda n: pr10 if n == 10 else pr20) @@ -700,6 +705,34 @@ def test_mine_data_compare_mode_no_pr_numbers_in_message(mocker, mock_repo): assert "bumpsha" in data.compare_commit_shas +def test_mine_data_compare_mode_excludes_sync_merge_commit_belonging_to_pr(mocker, mock_repo): + """A sync-merge commit (base branch merged back into the PR branch) has no PR-number reference in its + message, but it's still returned by pull.get_commits() for the PR it belongs to. It must not be + misclassified as a stand-alone direct commit (issue #335).""" + sync_merge_commit = mocker.Mock() + sync_merge_commit.sha = "syncmergesha" + sync_merge_commit.commit.message = "Merge branch 'main' into feature-x" + + squash_commit = mocker.Mock() + squash_commit.sha = "squashsha" + squash_commit.commit.message = "Feature X done (#7)" + + pr7 = mocker.Mock(spec=PullRequest) + pr7.number = 7 + pr7.get_commits.return_value = [sync_merge_commit, squash_commit] + + miner = _make_compare_miner( + mocker, + mock_repo, + compare_commits=[sync_merge_commit, squash_commit], + get_pull_side_effect=lambda n: pr7 if n == 7 else None, + ) + data = miner.mine_data() + + assert pr7 in data.pull_requests + assert data.commits == {} + + def test_mine_data_compare_mode_warns_on_total_commits_overflow(mocker, mock_repo): """Test that a warning is logged when the compare API returns more commits than it can retrieve (over 10,000).""" commit_mock = mocker.Mock() diff --git a/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py b/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py index 7da947fa..2fe6a3f4 100644 --- a/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py +++ b/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py @@ -48,6 +48,7 @@ def setup_no_issues_pulls_commits(mocker): mock_git_pr1.assignee = None mock_git_pr1.merge_commit_sha = "abc123" mock_git_pr1.get_labels.return_value = [] + mock_git_pr1.get_commits.return_value = [] mock_git_pr2 = mocker.Mock(spec=PullRequest) mock_git_pr2.id = 102 @@ -62,6 +63,7 @@ def setup_no_issues_pulls_commits(mocker): mock_git_pr2.assignee = None mock_git_pr2.merge_commit_sha = "def456" mock_git_pr2.get_labels.return_value = [] + mock_git_pr2.get_commits.return_value = [] mock_git_commit1 = mocker.Mock(spec=Commit) mock_git_commit1.sha = "abc123" @@ -136,6 +138,7 @@ def setup_issues_pulls_commits(mocker, mock_repo): mock_git_pr1.assignee = None mock_git_pr1.merge_commit_sha = "abc123" mock_git_pr1.get_labels.return_value = [] + mock_git_pr1.get_commits.return_value = [] mock_git_pr2 = mocker.Mock(spec=PullRequest) mock_git_pr2.id = 102 @@ -150,6 +153,7 @@ def setup_issues_pulls_commits(mocker, mock_repo): mock_git_pr2.assignee = None mock_git_pr2.merge_commit_sha = "def456" mock_git_pr2.get_labels.return_value = [] + mock_git_pr2.get_commits.return_value = [] mock_git_commit1 = mocker.Mock(spec=Commit) mock_git_commit1.sha = "abc123" @@ -215,6 +219,45 @@ def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): assert commit1 == rec_i1.get_commit(101, "abc123") +def test_generate_registers_sync_merge_commit_to_pr_not_as_direct_commit(mocker, mock_repo): + """A sync-merge commit (base branch merged back into the PR branch) is present in the base branch's + commit history but isn't pull.merge_commit_sha. pull.get_commits() still reports it as belonging to + the PR, so it must be registered to the PR/issue rather than misclassified as a direct commit + (issue #335).""" + mocker.patch( + "release_notes_generator.record.factory.default_record_factory.safe_call_decorator", + side_effect=mock_safe_call_decorator, + ) + mock_github_client = mocker.Mock(spec=Github) + issue1, _issue2, pr1, _pr2, commit1, commit2 = setup_issues_pulls_commits(mocker, mock_repo) + + sync_merge_commit = mocker.Mock(spec=Commit) + sync_merge_commit.sha = "syncmergesha" + sync_merge_commit.commit.message = "Merge branch 'main' into feature-x" + sync_merge_commit.author.login = "author1" + sync_merge_commit.repository = mock_repo + + pr1.get_commits.return_value = [commit1, sync_merge_commit] + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + data = MinedData(mock_repo) + data.issues = {issue1: mock_repo} + data.pull_requests = {pr1: mock_repo} + data.commits = {commit1: mock_repo, commit2: mock_repo, sync_merge_commit: mock_repo} + + records = DefaultRecordFactory(mock_github_client, mock_repo).generate(data) + + # The sync-merge commit must not appear as a stand-alone direct-commit record. + assert "syncmergesha" not in records + + rec_i1 = cast(IssueRecord, records["org/repo#1"]) + assert sync_merge_commit == rec_i1.get_commit(101, "syncmergesha") + + def test_generate_with_issues_and_pulls_and_commits_with_skip_labels(mocker, mock_repo): mocker.patch( "release_notes_generator.record.factory.default_record_factory.ActionInputs.get_skip_release_notes_labels", From f46ca6896aec3cc1ad101d707a5813af6c57d6a7 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 24 Aug 2026 12:02:03 +0200 Subject: [PATCH 2/5] Fixed review comments. --- release_notes_generator/data/miner.py | 8 +++++++- .../record/factory/default_record_factory.py | 3 ++- tests/unit/release_notes_generator/data/test_miner.py | 5 ++++- .../record/factory/test_default_record_factory.py | 6 +++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index a836913f..0733fc2f 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -147,7 +147,13 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: # including sync-merge commits (base branch merged back into the PR branch) whose # messages don't match _PR_NUMBER_RE. Excluding them by SHA (rather than by message # pattern) prevents them being misclassified as stand-alone "direct commits". - pr_commit_shas.update(c.sha for c in self._safe_call(pr.get_commits)() or []) + # merge_commit_sha is added separately: for a rebase-merge, get_commits() still + # reports the pre-rebase SHAs, not the new SHA(s) landed on the base branch. + pr_commits = self._safe_call(pr.get_commits)() + if pr_commits is not None: + pr_commit_shas.update(c.sha for c in pr_commits) + if pr.merge_commit_sha: + pr_commit_shas.add(pr.merge_commit_sha) data.pull_requests = pulls # Only include commits that aren't already accounted for by a PR diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 0e3ae087..1875bc70 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -130,7 +130,8 @@ def _register_pull_and_its_commits_to_issue( # dev note: pull.get_commits() returns all commits GitHub associates with the PR, including # sync-merge commits (base branch merged back into the PR branch). Without this, such commits # fall through and get misclassified as stand-alone "direct commits". - pr_commit_shas: set[str] = {c.sha for c in self._safe_call(pull.get_commits)() or []} + pr_commits = self._safe_call(pull.get_commits)() + pr_commit_shas: set[str] = {c.sha for c in pr_commits} if pr_commits is not None else set() if pull.merge_commit_sha: pr_commit_shas.add(pull.merge_commit_sha) related_commits = [c for c in data.commits if c.sha in pr_commit_shas] diff --git a/tests/unit/release_notes_generator/data/test_miner.py b/tests/unit/release_notes_generator/data/test_miner.py index d684216a..71007d7c 100644 --- a/tests/unit/release_notes_generator/data/test_miner.py +++ b/tests/unit/release_notes_generator/data/test_miner.py @@ -26,6 +26,7 @@ from github.Issue import Issue from github.PullRequest import PullRequest from github.Repository import Repository +from pytest_mock import MockerFixture from release_notes_generator.data.miner import DataMiner from release_notes_generator.data.utils.bulk_sub_issue_collector import BulkSubIssueCollector @@ -705,7 +706,9 @@ def test_mine_data_compare_mode_no_pr_numbers_in_message(mocker, mock_repo): assert "bumpsha" in data.compare_commit_shas -def test_mine_data_compare_mode_excludes_sync_merge_commit_belonging_to_pr(mocker, mock_repo): +def test_mine_data_compare_mode_excludes_sync_merge_commit_belonging_to_pr( + mocker: MockerFixture, mock_repo: Repository +) -> None: """A sync-merge commit (base branch merged back into the PR branch) has no PR-number reference in its message, but it's still returned by pull.get_commits() for the PR it belongs to. It must not be misclassified as a stand-alone direct commit (issue #335).""" diff --git a/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py b/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py index 2fe6a3f4..d6a23be6 100644 --- a/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py +++ b/tests/unit/release_notes_generator/record/factory/test_default_record_factory.py @@ -21,7 +21,9 @@ from github.Commit import Commit from github.Issue import Issue from github.PullRequest import PullRequest +from github.Repository import Repository from github.Requester import Requester +from pytest_mock import MockerFixture from release_notes_generator.model.record.commit_record import CommitRecord from release_notes_generator.model.record.hierarchy_issue_record import HierarchyIssueRecord @@ -219,7 +221,9 @@ def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): assert commit1 == rec_i1.get_commit(101, "abc123") -def test_generate_registers_sync_merge_commit_to_pr_not_as_direct_commit(mocker, mock_repo): +def test_generate_registers_sync_merge_commit_to_pr_not_as_direct_commit( + mocker: MockerFixture, mock_repo: Repository +) -> None: """A sync-merge commit (base branch merged back into the PR branch) is present in the base branch's commit history but isn't pull.merge_commit_sha. pull.get_commits() still reports it as belonging to the PR, so it must be registered to the PR/issue rather than misclassified as a direct commit From 965ee6987017975d92ef3fa691225fd39c746c72 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 24 Aug 2026 12:28:11 +0200 Subject: [PATCH 3/5] fix: ensure proper handling of pagination errors in commit retrieval and update type for 'since' attribute --- release_notes_generator/data/miner.py | 68 +++++++++++++------ release_notes_generator/model/mined_data.py | 3 +- .../record/factory/default_record_factory.py | 4 +- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 0733fc2f..66fff294 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -56,6 +56,10 @@ class DataMiner: def __init__(self, github_instance: Github, rate_limiter: GithubRateLimiter): self.github_instance = github_instance + # dev note: PyGithub paginated results are lazy - the HTTP requests happen while iterating, + # not on the initial call. Call sites that need a list must materialize it (e.g. via + # `self._safe_call(lambda: list(x.get_foo()))()`) inside the safe-call, otherwise pagination + # errors are raised outside of it and go uncaught. self._safe_call = safe_call_decorator(rate_limiter) def mine_data(self) -> MinedData: @@ -74,9 +78,9 @@ def mine_data(self) -> MinedData: if data.release is not None: prefer_published = ActionInputs.get_published_at() if prefer_published and getattr(data.release, "published_at", None) is not None: - data.since = data.release.published_at # type: ignore[assignment] + data.since = data.release.published_at elif getattr(data.release, "created_at", None) is not None: - data.since = data.release.created_at # type: ignore[assignment] + data.since = data.release.created_at else: data.since = None @@ -118,7 +122,15 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: to_tag, ) sys.exit(1) - compare_commits: list[GithubCommit] = list(comparison.commits) + compare_commits_result = self._safe_call(lambda: list(comparison.commits))() + if compare_commits_result is None: + logger.error( + "Compare API failed while retrieving commits for '%s'...'%s'. Ending!", + from_tag, + to_tag, + ) + sys.exit(1) + compare_commits: list[GithubCommit] = compare_commits_result total_commits = getattr(comparison, "total_commits", None) if isinstance(total_commits, int) and total_commits > len(compare_commits): logger.warning( @@ -149,7 +161,7 @@ def _handle_compare_mode(self, repo: Repository, data: MinedData) -> None: # pattern) prevents them being misclassified as stand-alone "direct commits". # merge_commit_sha is added separately: for a rebase-merge, get_commits() still # reports the pre-rebase SHAs, not the new SHA(s) landed on the base branch. - pr_commits = self._safe_call(pr.get_commits)() + pr_commits = self._safe_call(lambda p=pr: list(p.get_commits()))() if pr_commits is not None: pr_commit_shas.update(c.sha for c in pr_commits) if pr.merge_commit_sha: @@ -216,14 +228,18 @@ def _handle_since_time_mode(self, repo: Repository, data: MinedData) -> None: self._get_issues(data) # Fetch closed PRs and commits, then reduce them by the latest release since time - pull_requests = list( - self._safe_call(repo.get_pulls)(state=PullRequestRecord.PR_STATE_CLOSED, base=repo.default_branch) + pull_requests = ( + self._safe_call( + lambda: list(repo.get_pulls(state=PullRequestRecord.PR_STATE_CLOSED, base=repo.default_branch)) + )() + or [] ) data.pull_requests = {pr: data.home_repository for pr in pull_requests} if data.since: - commits = list(self._safe_call(repo.get_commits)(since=data.since)) + since = data.since + commits = self._safe_call(lambda: list(repo.get_commits(since=since)))() or [] else: - commits = list(self._safe_call(repo.get_commits)()) + commits = self._safe_call(lambda: list(repo.get_commits()))() or [] data.commits = {c: data.home_repository for c in commits} def mine_missing_sub_issues(self, data: MinedData) -> tuple[dict[Issue, Repository], dict[str, list[PullRequest]]]: @@ -470,7 +486,9 @@ def get_latest_release(self, repository: Repository) -> Optional[GitRelease]: else: logger.info("Getting latest release by semantic ordering (could not be the last one by time).") - gh_releases: list = list(self._safe_call(repository.get_releases)()) + # dev note: the list() materialization is done inside the safe-call so that pagination errors + # (raised while iterating the paginated result, not at call time) are also caught. + gh_releases: list = self._safe_call(lambda: list(repository.get_releases()))() or [] rls = self.__get_latest_semantic_release(gh_releases) if rls is None: @@ -499,8 +517,13 @@ def _get_issues(self, data: MinedData) -> None: assert data.home_repository is not None, "Repository must not be None" logger.info("Fetching issues from repository...") + # dev note: the list() materialization is done inside the safe-call so that pagination errors + # (raised while iterating the paginated result, not at call time) are also caught. if data.release is None: - issues = list(self._safe_call(data.home_repository.get_issues)(state=IssueRecord.ISSUE_STATE_ALL)) + issues = ( + self._safe_call(lambda: list(data.home_repository.get_issues(state=IssueRecord.ISSUE_STATE_ALL)))() + or [] + ) data.issues = {i: data.home_repository for i in issues} logger.info("Fetched %d issues", len(data.issues.items())) @@ -511,20 +534,23 @@ def _get_issues(self, data: MinedData) -> None: # Ensure data.since is only set if a valid datetime is available data.since = None if prefer_published and getattr(data.release, "published_at", None) is not None: - data.since = data.release.published_at # type: ignore[assignment] + data.since = data.release.published_at elif getattr(data.release, "created_at", None) is not None: - data.since = data.release.created_at # type: ignore[assignment] + data.since = data.release.created_at - issues_since = self._safe_call(data.home_repository.get_issues)( - state=IssueRecord.ISSUE_STATE_ALL, - since=data.since, - ) - open_issues = self._safe_call(data.home_repository.get_issues)( - state=IssueRecord.ISSUE_STATE_OPEN, - ) + since = data.since + + def _fetch_issues_since() -> list[Issue]: + return list( + data.home_repository.get_issues( + state=IssueRecord.ISSUE_STATE_ALL, since=since # type: ignore[arg-type] + ) + ) - issues_since = list(issues_since or []) - open_issues = list(open_issues or []) + issues_since = self._safe_call(_fetch_issues_since)() or [] + open_issues = ( + self._safe_call(lambda: list(data.home_repository.get_issues(state=IssueRecord.ISSUE_STATE_OPEN)))() or [] + ) by_number = {} for issue in issues_since: diff --git a/release_notes_generator/model/mined_data.py b/release_notes_generator/model/mined_data.py index 49a9eede..6b994b77 100644 --- a/release_notes_generator/model/mined_data.py +++ b/release_notes_generator/model/mined_data.py @@ -21,6 +21,7 @@ import logging from dataclasses import dataclass +from datetime import datetime from typing import Optional from github.GitRelease import GitRelease @@ -41,7 +42,7 @@ def __init__(self, repository: Repository): self._repositories: dict[str, Repository] = {repository.full_name: repository} self.release: Optional[GitRelease] = None - self.since = None + self.since: Optional[datetime] = None # self.since = datetime(1970, 1, 1) # Default to epoch start self.issues: dict[Issue, Repository] = {} diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 1875bc70..aab08e50 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -130,7 +130,9 @@ def _register_pull_and_its_commits_to_issue( # dev note: pull.get_commits() returns all commits GitHub associates with the PR, including # sync-merge commits (base branch merged back into the PR branch). Without this, such commits # fall through and get misclassified as stand-alone "direct commits". - pr_commits = self._safe_call(pull.get_commits)() + # The list() materialization is done inside the safe-call so that pagination errors (raised + # while iterating the paginated result, not at call time) are also caught and handled. + pr_commits = self._safe_call(lambda: list(pull.get_commits()))() pr_commit_shas: set[str] = {c.sha for c in pr_commits} if pr_commits is not None else set() if pull.merge_commit_sha: pr_commit_shas.add(pull.merge_commit_sha) From 5627697ee26aaefa6c0c4b1159ff8d4c21e4c916 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 24 Aug 2026 12:34:53 +0200 Subject: [PATCH 4/5] fix: remove developer notes regarding pagination error handling in release and issue fetching --- release_notes_generator/data/miner.py | 4 ---- .../record/factory/default_record_factory.py | 2 -- 2 files changed, 6 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 66fff294..5db6f150 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -486,8 +486,6 @@ def get_latest_release(self, repository: Repository) -> Optional[GitRelease]: else: logger.info("Getting latest release by semantic ordering (could not be the last one by time).") - # dev note: the list() materialization is done inside the safe-call so that pagination errors - # (raised while iterating the paginated result, not at call time) are also caught. gh_releases: list = self._safe_call(lambda: list(repository.get_releases()))() or [] rls = self.__get_latest_semantic_release(gh_releases) @@ -517,8 +515,6 @@ def _get_issues(self, data: MinedData) -> None: assert data.home_repository is not None, "Repository must not be None" logger.info("Fetching issues from repository...") - # dev note: the list() materialization is done inside the safe-call so that pagination errors - # (raised while iterating the paginated result, not at call time) are also caught. if data.release is None: issues = ( self._safe_call(lambda: list(data.home_repository.get_issues(state=IssueRecord.ISSUE_STATE_ALL)))() diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index aab08e50..1231a431 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -130,8 +130,6 @@ def _register_pull_and_its_commits_to_issue( # dev note: pull.get_commits() returns all commits GitHub associates with the PR, including # sync-merge commits (base branch merged back into the PR branch). Without this, such commits # fall through and get misclassified as stand-alone "direct commits". - # The list() materialization is done inside the safe-call so that pagination errors (raised - # while iterating the paginated result, not at call time) are also caught and handled. pr_commits = self._safe_call(lambda: list(pull.get_commits()))() pr_commit_shas: set[str] = {c.sha for c in pr_commits} if pr_commits is not None else set() if pull.merge_commit_sha: From 04d0d27c887e0c01ac40bcd5681cf540d32a7845 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 24 Aug 2026 13:02:21 +0200 Subject: [PATCH 5/5] fix: streamline issue retrieval logic in DataMiner class --- release_notes_generator/data/miner.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 5db6f150..8dccd4c6 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -534,20 +534,17 @@ def _get_issues(self, data: MinedData) -> None: elif getattr(data.release, "created_at", None) is not None: data.since = data.release.created_at - since = data.since - - def _fetch_issues_since() -> list[Issue]: - return list( - data.home_repository.get_issues( - state=IssueRecord.ISSUE_STATE_ALL, since=since # type: ignore[arg-type] - ) - ) - - issues_since = self._safe_call(_fetch_issues_since)() or [] - open_issues = ( - self._safe_call(lambda: list(data.home_repository.get_issues(state=IssueRecord.ISSUE_STATE_OPEN)))() or [] + issues_since = self._safe_call(data.home_repository.get_issues)( + state=IssueRecord.ISSUE_STATE_ALL, + since=data.since, + ) + open_issues = self._safe_call(data.home_repository.get_issues)( + state=IssueRecord.ISSUE_STATE_OPEN, ) + issues_since = list(issues_since or []) + open_issues = list(open_issues or []) + by_number = {} for issue in issues_since: num = getattr(issue, "number", None)