From ad792b81378b461ccd27ce1570a5a986bbb1f5e3 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 3 Jun 2026 13:24:39 +0200 Subject: [PATCH] Fix cross-year GitHub activity ranges --- app/sources/github.py | 76 ++++++++++++++++++++++++++------ tests/test_github_source.py | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 tests/test_github_source.py diff --git a/app/sources/github.py b/app/sources/github.py index 2d438b5..830840d 100644 --- a/app/sources/github.py +++ b/app/sources/github.py @@ -11,6 +11,15 @@ class GitHubSourceError(RuntimeError): pass +def _iter_year_ranges(from_date: date, to_date: date) -> list[tuple[date, date]]: + ranges: list[tuple[date, date]] = [] + for year in range(from_date.year, to_date.year + 1): + range_start = max(from_date, date(year, 1, 1)) + range_end = min(to_date, date(year, 12, 31)) + ranges.append((range_start, range_end)) + return ranges + + def _extract_attr(tag: str, attr: str) -> str | None: match = re.search(rf'{attr}="([^"]+)"', tag) return match.group(1) if match else None @@ -42,7 +51,7 @@ def _parse_public_contributions_html(html: str, from_date: date, to_date: date) return normalized -async def _fetch_github_activity_public( +async def _fetch_github_activity_public_range( username: str, from_date: date, to_date: date, @@ -66,21 +75,32 @@ async def _fetch_github_activity_public( return _parse_public_contributions_html(response.text, from_date, to_date) -async def fetch_github_activity( +async def _fetch_github_activity_public( username: str, - token: str | None, from_date: date, to_date: date, - timeout_seconds: float = 20.0, + timeout_seconds: float, ) -> dict[str, int]: - if not token: - return await _fetch_github_activity_public( - username=username, - from_date=from_date, - to_date=to_date, - timeout_seconds=timeout_seconds, + normalized: dict[str, int] = {} + for range_start, range_end in _iter_year_ranges(from_date, to_date): + normalized.update( + await _fetch_github_activity_public_range( + username=username, + from_date=range_start, + to_date=range_end, + timeout_seconds=timeout_seconds, + ) ) + return normalized + +async def _fetch_github_activity_graphql_range( + username: str, + token: str, + from_date: date, + to_date: date, + timeout_seconds: float, +) -> dict[str, int]: query = """ query($login: String!, $from: DateTime!, $to: DateTime!) { user(login: $login) { @@ -103,9 +123,10 @@ async def fetch_github_activity( "to": datetime.combine(to_date, datetime.max.time(), tzinfo=timezone.utc).isoformat(), } - headers: dict[str, str] = {"Accept": "application/json"} - if token: - headers["Authorization"] = f"bearer {token}" + headers: dict[str, str] = { + "Accept": "application/json", + "Authorization": f"bearer {token}", + } async with httpx.AsyncClient(timeout=timeout_seconds) as client: response = await client.post( @@ -140,3 +161,32 @@ async def fetch_github_activity( normalized[date_key] = int(day.get("contributionCount", 0)) return normalized + + +async def fetch_github_activity( + username: str, + token: str | None, + from_date: date, + to_date: date, + timeout_seconds: float = 20.0, +) -> dict[str, int]: + if not token: + return await _fetch_github_activity_public( + username=username, + from_date=from_date, + to_date=to_date, + timeout_seconds=timeout_seconds, + ) + + normalized: dict[str, int] = {} + for range_start, range_end in _iter_year_ranges(from_date, to_date): + normalized.update( + await _fetch_github_activity_graphql_range( + username=username, + token=token, + from_date=range_start, + to_date=range_end, + timeout_seconds=timeout_seconds, + ) + ) + return normalized diff --git a/tests/test_github_source.py b/tests/test_github_source.py new file mode 100644 index 0000000..b3f65ad --- /dev/null +++ b/tests/test_github_source.py @@ -0,0 +1,88 @@ +from datetime import date + +import pytest + +from app.sources import github as github_source + + +def test_iter_year_ranges_splits_cross_year_window() -> None: + ranges = github_source._iter_year_ranges(date(2025, 6, 1), date(2026, 6, 1)) + + assert ranges == [ + (date(2025, 6, 1), date(2025, 12, 31)), + (date(2026, 1, 1), date(2026, 6, 1)), + ] + + +@pytest.mark.asyncio +async def test_fetch_github_activity_without_token_merges_year_slices(monkeypatch) -> None: + requested_ranges: list[tuple[date, date]] = [] + + async def fake_fetch_public_range(username, from_date, to_date, timeout_seconds): + requested_ranges.append((from_date, to_date)) + return { + from_date.isoformat(): 1, + to_date.isoformat(): 2, + } + + monkeypatch.setattr( + github_source, + "_fetch_github_activity_public_range", + fake_fetch_public_range, + ) + + activity = await github_source.fetch_github_activity( + username="octocat", + token=None, + from_date=date(2025, 6, 1), + to_date=date(2026, 6, 1), + timeout_seconds=1.0, + ) + + assert requested_ranges == [ + (date(2025, 6, 1), date(2025, 12, 31)), + (date(2026, 1, 1), date(2026, 6, 1)), + ] + assert activity == { + "2025-06-01": 1, + "2025-12-31": 2, + "2026-01-01": 1, + "2026-06-01": 2, + } + + +@pytest.mark.asyncio +async def test_fetch_github_activity_with_token_merges_year_slices(monkeypatch) -> None: + requested_ranges: list[tuple[date, date, str]] = [] + + async def fake_fetch_graphql_range(username, token, from_date, to_date, timeout_seconds): + requested_ranges.append((from_date, to_date, token)) + return { + from_date.isoformat(): 3, + to_date.isoformat(): 4, + } + + monkeypatch.setattr( + github_source, + "_fetch_github_activity_graphql_range", + fake_fetch_graphql_range, + ) + + activity = await github_source.fetch_github_activity( + username="octocat", + token="secret", + from_date=date(2025, 6, 1), + to_date=date(2026, 6, 1), + timeout_seconds=1.0, + ) + + assert requested_ranges == [ + (date(2025, 6, 1), date(2025, 12, 31), "secret"), + (date(2026, 1, 1), date(2026, 6, 1), "secret"), + ] + assert activity == { + "2025-06-01": 3, + "2025-12-31": 4, + "2026-01-01": 3, + "2026-06-01": 4, + }