Compare commits
2 Commits
16761bb82d
...
ad792b8137
| Author | SHA1 | Date | |
|---|---|---|---|
| ad792b8137 | |||
| 490f053c64 |
@@ -36,6 +36,8 @@ Supported query params:
|
|||||||
- `theme=dark|light`
|
- `theme=dark|light`
|
||||||
- `source=all|github|gitea`
|
- `source=all|github|gitea`
|
||||||
|
|
||||||
|
Responses also include an `X-Activity-Stale: true|false` header so image consumers can detect when stale cached data was used after an upstream fetch failure.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+39
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -128,8 +129,14 @@ async def collect_merged_activity(
|
|||||||
github_stale = False
|
github_stale = False
|
||||||
gitea_stale = False
|
gitea_stale = False
|
||||||
|
|
||||||
|
fetch_names: list[str] = []
|
||||||
|
fetch_tasks: list[asyncio.Task[tuple[dict[str, int], bool]]] = []
|
||||||
|
|
||||||
if options.source in ("all", "github"):
|
if options.source in ("all", "github"):
|
||||||
github_data, github_stale = await _fetch_with_cache(
|
fetch_names.append("github")
|
||||||
|
fetch_tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
_fetch_with_cache(
|
||||||
cache,
|
cache,
|
||||||
gh_key,
|
gh_key,
|
||||||
lambda: fetch_github_activity(
|
lambda: fetch_github_activity(
|
||||||
@@ -138,10 +145,15 @@ async def collect_merged_activity(
|
|||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if options.source in ("all", "gitea"):
|
if options.source in ("all", "gitea"):
|
||||||
gitea_data, gitea_stale = await _fetch_with_cache(
|
fetch_names.append("gitea")
|
||||||
|
fetch_tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
_fetch_with_cache(
|
||||||
cache,
|
cache,
|
||||||
gt_key,
|
gt_key,
|
||||||
lambda: fetch_gitea_activity(
|
lambda: fetch_gitea_activity(
|
||||||
@@ -151,7 +163,15 @@ async def collect_merged_activity(
|
|||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, (data, stale) in zip(fetch_names, await asyncio.gather(*fetch_tasks), strict=True):
|
||||||
|
if name == "github":
|
||||||
|
github_data, github_stale = data, stale
|
||||||
|
else:
|
||||||
|
gitea_data, gitea_stale = data, stale
|
||||||
|
|
||||||
merged = merge_activity(github_data, gitea_data, dates=_date_keys(from_date, to_date))
|
merged = merge_activity(github_data, gitea_data, dates=_date_keys(from_date, to_date))
|
||||||
merged = filter_activity_source(merged, options.source)
|
merged = filter_activity_source(merged, options.source)
|
||||||
@@ -169,6 +189,10 @@ def _daily_totals(merged: dict[str, dict[str, int]]) -> dict[str, int]:
|
|||||||
return {day: int(payload.get("total", 0)) for day, payload in merged.items()}
|
return {day: int(payload.get("total", 0)) for day, payload in merged.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_headers(result: ActivityResult) -> dict[str, str]:
|
||||||
|
return {"X-Activity-Stale": "true" if result.stale else "false"}
|
||||||
|
|
||||||
|
|
||||||
def _image_cache_key(prefix: str, options: QueryOptions, result: ActivityResult) -> str:
|
def _image_cache_key(prefix: str, options: QueryOptions, result: ActivityResult) -> str:
|
||||||
digest = hashlib.sha1(
|
digest = hashlib.sha1(
|
||||||
json.dumps(result.merged, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
json.dumps(result.merged, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
@@ -203,7 +227,7 @@ async def activity_json(
|
|||||||
"stale": result.stale,
|
"stale": result.stale,
|
||||||
"activity": result.merged,
|
"activity": result.merged,
|
||||||
}
|
}
|
||||||
return JSONResponse(content=payload)
|
return JSONResponse(content=payload, headers=_activity_headers(result))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/activity.svg")
|
@app.get("/activity.svg")
|
||||||
@@ -222,7 +246,11 @@ async def activity_svg(
|
|||||||
if cache is not None:
|
if cache is not None:
|
||||||
cached = cache.get_json(cache_key)
|
cached = cache.get_json(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return Response(content=str(cached.value), media_type="image/svg+xml")
|
return Response(
|
||||||
|
content=str(cached.value),
|
||||||
|
media_type="image/svg+xml",
|
||||||
|
headers=_activity_headers(result),
|
||||||
|
)
|
||||||
|
|
||||||
daily_totals = _daily_totals(result.merged)
|
daily_totals = _daily_totals(result.merged)
|
||||||
total = sum(daily_totals.values())
|
total = sum(daily_totals.values())
|
||||||
@@ -236,7 +264,7 @@ async def activity_svg(
|
|||||||
)
|
)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
cache.set_json(cache_key, svg)
|
cache.set_json(cache_key, svg)
|
||||||
return Response(content=svg, media_type="image/svg+xml")
|
return Response(content=svg, media_type="image/svg+xml", headers=_activity_headers(result))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/activity.png")
|
@app.get("/activity.png")
|
||||||
@@ -256,7 +284,11 @@ async def activity_png(
|
|||||||
cached = cache.get_json(cache_key)
|
cached = cache.get_json(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
png_data = bytes.fromhex(str(cached.value))
|
png_data = bytes.fromhex(str(cached.value))
|
||||||
return Response(content=png_data, media_type="image/png")
|
return Response(
|
||||||
|
content=png_data,
|
||||||
|
media_type="image/png",
|
||||||
|
headers=_activity_headers(result),
|
||||||
|
)
|
||||||
|
|
||||||
daily_totals = _daily_totals(result.merged)
|
daily_totals = _daily_totals(result.merged)
|
||||||
total = sum(daily_totals.values())
|
total = sum(daily_totals.values())
|
||||||
@@ -275,4 +307,4 @@ async def activity_png(
|
|||||||
raise HTTPException(status_code=500, detail="PNG rendering failed") from exc
|
raise HTTPException(status_code=500, detail="PNG rendering failed") from exc
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
cache.set_json(cache_key, png_data.hex())
|
cache.set_json(cache_key, png_data.hex())
|
||||||
return Response(content=png_data, media_type="image/png")
|
return Response(content=png_data, media_type="image/png", headers=_activity_headers(result))
|
||||||
|
|||||||
+61
-11
@@ -11,6 +11,15 @@ class GitHubSourceError(RuntimeError):
|
|||||||
pass
|
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:
|
def _extract_attr(tag: str, attr: str) -> str | None:
|
||||||
match = re.search(rf'{attr}="([^"]+)"', tag)
|
match = re.search(rf'{attr}="([^"]+)"', tag)
|
||||||
return match.group(1) if match else None
|
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
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_github_activity_public(
|
async def _fetch_github_activity_public_range(
|
||||||
username: str,
|
username: str,
|
||||||
from_date: date,
|
from_date: date,
|
||||||
to_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)
|
return _parse_public_contributions_html(response.text, from_date, to_date)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_github_activity(
|
async def _fetch_github_activity_public(
|
||||||
username: str,
|
username: str,
|
||||||
token: str | None,
|
|
||||||
from_date: date,
|
from_date: date,
|
||||||
to_date: date,
|
to_date: date,
|
||||||
timeout_seconds: float = 20.0,
|
timeout_seconds: float,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
if not token:
|
normalized: dict[str, int] = {}
|
||||||
return await _fetch_github_activity_public(
|
for range_start, range_end in _iter_year_ranges(from_date, to_date):
|
||||||
|
normalized.update(
|
||||||
|
await _fetch_github_activity_public_range(
|
||||||
username=username,
|
username=username,
|
||||||
from_date=from_date,
|
from_date=range_start,
|
||||||
to_date=to_date,
|
to_date=range_end,
|
||||||
timeout_seconds=timeout_seconds,
|
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 = """
|
||||||
query($login: String!, $from: DateTime!, $to: DateTime!) {
|
query($login: String!, $from: DateTime!, $to: DateTime!) {
|
||||||
user(login: $login) {
|
user(login: $login) {
|
||||||
@@ -103,9 +123,10 @@ async def fetch_github_activity(
|
|||||||
"to": datetime.combine(to_date, datetime.max.time(), tzinfo=timezone.utc).isoformat(),
|
"to": datetime.combine(to_date, datetime.max.time(), tzinfo=timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
headers: dict[str, str] = {"Accept": "application/json"}
|
headers: dict[str, str] = {
|
||||||
if token:
|
"Accept": "application/json",
|
||||||
headers["Authorization"] = f"bearer {token}"
|
"Authorization": f"bearer {token}",
|
||||||
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -140,3 +161,32 @@ async def fetch_github_activity(
|
|||||||
normalized[date_key] = int(day.get("contributionCount", 0))
|
normalized[date_key] = int(day.get("contributionCount", 0))
|
||||||
|
|
||||||
return normalized
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -8,6 +8,31 @@ from app.main import ActivityResult, app
|
|||||||
from app.settings import get_settings
|
from app.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_json_reports_stale_header(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("GITHUB_USERNAME", "octocat")
|
||||||
|
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com")
|
||||||
|
monkeypatch.setenv("GITEA_USERNAME", "octocat")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
async def fake_collect_merged_activity(settings, cache, options):
|
||||||
|
return ActivityResult(
|
||||||
|
merged={"2026-01-01": {"github": 1, "gitea": 2, "total": 3}},
|
||||||
|
stale=True,
|
||||||
|
from_date=date(2026, 1, 1),
|
||||||
|
to_date=date(2026, 1, 1),
|
||||||
|
days_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/activity.json")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["x-activity-stale"] == "true"
|
||||||
|
assert response.json()["stale"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_activity_svg_returns_svg_content_type(monkeypatch) -> None:
|
def test_activity_svg_returns_svg_content_type(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("GITHUB_USERNAME", "octocat")
|
monkeypatch.setenv("GITHUB_USERNAME", "octocat")
|
||||||
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com")
|
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com")
|
||||||
@@ -35,4 +60,18 @@ def test_activity_svg_returns_svg_content_type(monkeypatch) -> None:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"].startswith("image/svg+xml")
|
assert response.headers["content-type"].startswith("image/svg+xml")
|
||||||
|
assert response.headers["x-activity-stale"] == "false"
|
||||||
assert "<svg" in response.text
|
assert "<svg" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_json_rejects_year_and_days_together(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("GITHUB_USERNAME", "octocat")
|
||||||
|
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com")
|
||||||
|
monkeypatch.setenv("GITEA_USERNAME", "octocat")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/activity.json?year=2026&days=30")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "Provide either year or days, not both"}
|
||||||
|
|||||||
Reference in New Issue
Block a user