Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions tagbot/action/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -151,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 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
Expand All @@ -178,7 +193,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
Expand Down Expand Up @@ -279,13 +294,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}")
Expand Down
46 changes: 46 additions & 0 deletions test/action/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -153,6 +154,51 @@ 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 = []
Expand Down