From 1698d131205c4a0faa816219619e64e4258764ca Mon Sep 17 00:00:00 2001 From: Ian Butterworth Date: Wed, 18 Feb 2026 14:54:23 +0000 Subject: [PATCH 1/2] Fix offset-naive vs offset-aware datetime comparison in changelog PyGithub may return naive or aware datetimes for closed_at and created_at depending on version. Add _ensure_utc() helper to normalize datetimes before comparison, preventing TypeError. Fixes #492 Co-authored-by: Claude --- tagbot/action/changelog.py | 19 ++++++++++--- test/action/test_changelog.py | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/tagbot/action/changelog.py b/tagbot/action/changelog.py index 6cac020f..66ac9379 100644 --- a/tagbot/action/changelog.py +++ b/tagbot/action/changelog.py @@ -18,6 +18,17 @@ from .repo import Repo +def _ensure_utc(dt: datetime) -> datetime: + """Ensure a datetime is timezone-aware (UTC). + + PyGithub may return naive or aware datetimes depending on version. + This normalizes them so comparisons don't raise TypeError. + """ + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + + class Changelog: """A Changelog produces release notes for a single release.""" @@ -151,7 +162,7 @@ def _issues_and_pulls( for x in gh.search_issues(query, sort="created", order="asc"): # Search returns issues, need to filter by closed_at within range # (search date range is approximate, so we still need to verify) - if x.closed_at is None or x.closed_at <= start or x.closed_at > end: + if x.closed_at is None or _ensure_utc(x.closed_at) <= start or _ensure_utc(x.closed_at) > end: continue if self._ignore.intersection( self._slug(label.name) for label in x.labels @@ -178,7 +189,7 @@ def _issues_and_pulls_fallback( """Fallback method using the issues API (slower but more reliable).""" xs: List[Union[Issue, PullRequest]] = [] for x in self._repo._repo.get_issues(state="closed", since=start): - if x.closed_at <= start or x.closed_at > end: + if _ensure_utc(x.closed_at) <= start or _ensure_utc(x.closed_at) > end: continue if self._ignore.intersection(self._slug(label.name) for label in x.labels): continue @@ -279,13 +290,13 @@ def _collect_data(self, version_tag: str, sha: str) -> Dict[str, object]: compare = None if previous: if previous.created_at: - start = previous.created_at + start = _ensure_utc(previous.created_at) prev_tag = previous.tag_name compare = f"{self._repo._repo.html_url}/compare/{prev_tag}...{version_tag}" # When the last commit is a PR merge, the commit happens a second or two before # the PR and associated issues are closed. commit = self._repo._repo.get_commit(sha) - end = commit.commit.author.date + timedelta(minutes=1) + end = _ensure_utc(commit.commit.author.date) + timedelta(minutes=1) logger.debug(f"Previous version: {prev_tag}") logger.debug(f"Start date: {start}") logger.debug(f"End date: {end}") diff --git a/test/action/test_changelog.py b/test/action/test_changelog.py index 34cf5666..fb5d6fcd 100644 --- a/test/action/test_changelog.py +++ b/test/action/test_changelog.py @@ -9,6 +9,7 @@ from github.Issue import Issue from github.PullRequest import PullRequest +from tagbot.action.changelog import _ensure_utc from tagbot.action.repo import Repo @@ -153,6 +154,55 @@ def test_issues_and_pulls(): assert [x.n for x in c._issues_and_pulls(start, end)] == [5, 4, 3] +def test_ensure_utc(): + naive = datetime(2024, 1, 1, 12, 0, 0) + aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + assert _ensure_utc(naive) == aware + assert _ensure_utc(naive).tzinfo is timezone.utc + assert _ensure_utc(aware) is aware + + +def test_issues_and_pulls_mixed_timezones(): + """Verify no TypeError when closed_at is naive but start/end are aware.""" + c = _changelog() + now = datetime.now(timezone.utc) + start = now - timedelta(days=10) + end = now + c._repo._repo = Mock() + c._repo._repo.full_name = "owner/repo" + # Return a naive datetime for closed_at (simulating older PyGithub) + naive_closed = (end - timedelta(days=5)).replace(tzinfo=None) + c._repo._repo.get_issues = Mock( + return_value=[ + Mock(closed_at=naive_closed, n=1, pull_request=False, labels=[]) + ] + ) + mock_gh = Mock() + mock_gh.search_issues.side_effect = Exception("search failed") + c._repo._gh = mock_gh + result = c._issues_and_pulls(start, end) + assert len(result) == 1 + + +def test_issues_and_pulls_search_mixed_timezones(): + """Verify no TypeError when search API returns naive closed_at.""" + c = _changelog() + now = datetime.now(timezone.utc) + start = now - timedelta(days=10) + end = now + c._repo._repo = Mock() + c._repo._repo.full_name = "owner/repo" + naive_closed = (end - timedelta(days=5)).replace(tzinfo=None) + issue = Mock( + closed_at=naive_closed, pull_request=False, labels=[] + ) + mock_gh = Mock() + mock_gh.search_issues.return_value = [issue] + c._repo._gh = mock_gh + result = c._issues_and_pulls(start, end) + assert len(result) == 1 + + def test_issues_pulls(): c = _changelog() mocks = [] From dcb972e191f54bd72f59e3597b560b23bda2909f Mon Sep 17 00:00:00 2001 From: Ian Butterworth Date: Wed, 18 Feb 2026 15:09:50 +0000 Subject: [PATCH 2/2] format --- tagbot/action/changelog.py | 6 +++++- test/action/test_changelog.py | 8 ++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tagbot/action/changelog.py b/tagbot/action/changelog.py index 66ac9379..e0336dae 100644 --- a/tagbot/action/changelog.py +++ b/tagbot/action/changelog.py @@ -162,7 +162,11 @@ def _issues_and_pulls( for x in gh.search_issues(query, sort="created", order="asc"): # Search returns issues, need to filter by closed_at within range # (search date range is approximate, so we still need to verify) - if x.closed_at is None or _ensure_utc(x.closed_at) <= start or _ensure_utc(x.closed_at) > end: + if ( + x.closed_at is None + or _ensure_utc(x.closed_at) <= start + or _ensure_utc(x.closed_at) > end + ): continue if self._ignore.intersection( self._slug(label.name) for label in x.labels diff --git a/test/action/test_changelog.py b/test/action/test_changelog.py index fb5d6fcd..4823674c 100644 --- a/test/action/test_changelog.py +++ b/test/action/test_changelog.py @@ -173,9 +173,7 @@ def test_issues_and_pulls_mixed_timezones(): # Return a naive datetime for closed_at (simulating older PyGithub) naive_closed = (end - timedelta(days=5)).replace(tzinfo=None) c._repo._repo.get_issues = Mock( - return_value=[ - Mock(closed_at=naive_closed, n=1, pull_request=False, labels=[]) - ] + return_value=[Mock(closed_at=naive_closed, n=1, pull_request=False, labels=[])] ) mock_gh = Mock() mock_gh.search_issues.side_effect = Exception("search failed") @@ -193,9 +191,7 @@ def test_issues_and_pulls_search_mixed_timezones(): c._repo._repo = Mock() c._repo._repo.full_name = "owner/repo" naive_closed = (end - timedelta(days=5)).replace(tzinfo=None) - issue = Mock( - closed_at=naive_closed, pull_request=False, labels=[] - ) + issue = Mock(closed_at=naive_closed, pull_request=False, labels=[]) mock_gh = Mock() mock_gh.search_issues.return_value = [issue] c._repo._gh = mock_gh