Compare commits

...

3 Commits

Author SHA1 Message Date
Space-Banane c94bc7e5d4 Surface programming errors in user fetches
ci / push-image (push) Successful in 1m14s
ci / deploy-coolify (push) Successful in 7s
ci / test (push) Successful in 36s
2026-06-04 22:56:11 +02:00
Space-Banane c622420e38 Harden multi-user activity fetching
ci / deploy-coolify (push) Successful in 7s
ci / test (push) Successful in 13s
ci / push-image (push) Successful in 55s
2026-06-04 22:51:48 +02:00
Space-Banane 4444174365 Add multi-username activity support
ci / test (push) Successful in 10s
ci / push-image (push) Successful in 52s
ci / deploy-coolify (push) Successful in 6s
2026-06-04 22:42:48 +02:00
5 changed files with 252 additions and 44 deletions
+9 -2
View File
@@ -62,9 +62,16 @@ Copy `.env.example` to `.env` and edit values.
Required:
- `GITHUB_USERNAME`
- `GITHUB_USERNAME` (supports a comma-separated list, for example `octocat,hubot`)
- `GITEA_BASE_URL`
- `GITEA_USERNAME`
- `GITEA_USERNAME` (supports a comma-separated list)
Example:
```env
GITHUB_USERNAME=octocat,hubot
GITEA_USERNAME=alice,bob
```
Optional:
+81 -41
View File
@@ -114,6 +114,58 @@ async def _fetch_with_cache(
raise
async def _fetch_source_usernames(
cache: FileCache | None,
source_prefix: str,
usernames: list[str],
from_date: date,
to_date: date,
fetcher_factory,
) -> tuple[dict[str, int], bool]:
if not usernames:
return {}, False
fetch_tasks: list[asyncio.Task[tuple[str, tuple[dict[str, int], bool] | Exception]]] = []
for username in usernames:
key = f"{source_prefix}_{username}_{from_date}_{to_date}"
fetch_tasks.append(
asyncio.create_task(
_fetch_source_username(cache, key, username, fetcher_factory(username))
)
)
combined: dict[str, int] = {}
stale = False
failures: list[Exception] = []
for username, result in await asyncio.gather(*fetch_tasks):
if isinstance(result, Exception):
logger.warning("source fetch failed for %s/%s, skipping user: %s", source_prefix, username, result)
failures.append(result)
continue
data, entry_stale = result
stale = stale or entry_stale
for day, count in data.items():
combined[day] = combined.get(day, 0) + int(count)
if not combined and failures:
raise failures[0]
return combined, stale
async def _fetch_source_username(
cache: FileCache | None,
key: str,
username: str,
fetcher,
) -> tuple[str, tuple[dict[str, int], bool] | Exception]:
try:
return username, await _fetch_with_cache(cache, key, fetcher)
except (GitHubSourceError, GiteaSourceError) as exc:
return username, exc
async def collect_merged_activity(
settings: Settings,
cache: FileCache | None,
@@ -121,58 +173,46 @@ async def collect_merged_activity(
) -> ActivityResult:
from_date, to_date, days_count = compute_date_range(options)
gh_key = f"github_{settings.github_username}_{from_date}_{to_date}"
gt_key = f"gitea_{settings.gitea_username}_{from_date}_{to_date}"
github_data: dict[str, int] = {}
gitea_data: dict[str, int] = {}
github_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"):
fetch_names.append("github")
fetch_tasks.append(
asyncio.create_task(
_fetch_with_cache(
cache,
gh_key,
lambda: fetch_github_activity(
username=settings.github_username,
token=settings.github_token,
from_date=from_date,
to_date=to_date,
),
),
)
github_data, github_stale = await _fetch_source_usernames(
cache,
"github",
settings.github_usernames,
from_date,
to_date,
lambda username: (
lambda: fetch_github_activity(
username=username,
token=settings.github_token,
from_date=from_date,
to_date=to_date,
)
),
)
if options.source in ("all", "gitea"):
fetch_names.append("gitea")
fetch_tasks.append(
asyncio.create_task(
_fetch_with_cache(
cache,
gt_key,
lambda: fetch_gitea_activity(
base_url=settings.gitea_base_url,
username=settings.gitea_username,
token=settings.gitea_token,
from_date=from_date,
to_date=to_date,
),
),
)
gitea_data, gitea_stale = await _fetch_source_usernames(
cache,
"gitea",
settings.gitea_usernames,
from_date,
to_date,
lambda username: (
lambda: fetch_gitea_activity(
base_url=settings.gitea_base_url,
username=username,
token=settings.gitea_token,
from_date=from_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 = filter_activity_source(merged, options.source)
+28 -1
View File
@@ -1,9 +1,21 @@
from functools import lru_cache
from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def split_usernames(raw: str) -> list[str]:
usernames: list[str] = []
seen: set[str] = set()
for part in raw.split(","):
username = part.strip()
if not username or username in seen:
continue
seen.add(username)
usernames.append(username)
return usernames
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
@@ -20,6 +32,21 @@ class Settings(BaseSettings):
default_theme: str = Field(default="light", alias="DEFAULT_THEME")
service_title: str = Field(default="git-activity-merge", alias="SERVICE_TITLE")
@field_validator("github_username", "gitea_username")
@classmethod
def _validate_username_list(cls, value: str) -> str:
if not split_usernames(value):
raise ValueError("must contain at least one username")
return value
@property
def github_usernames(self) -> list[str]:
return split_usernames(self.github_username)
@property
def gitea_usernames(self) -> list[str]:
return split_usernames(self.gitea_username)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+125
View File
@@ -0,0 +1,125 @@
import tempfile
from datetime import date
from pathlib import Path
import pytest
from app.main import QueryOptions, collect_merged_activity, _fetch_source_usernames
from app.cache import FileCache
from app.settings import Settings
from app.sources import github as github_source
@pytest.mark.asyncio
async def test_collect_merged_activity_combines_multiple_usernames(monkeypatch) -> None:
settings = Settings(
_env_file=None,
GITHUB_USERNAME="octocat, hubot",
GITEA_BASE_URL="https://gitea.example.com",
GITEA_USERNAME="alice, bob",
)
github_calls: list[str] = []
gitea_calls: list[str] = []
async def fake_fetch_github_activity(username, token, from_date, to_date, timeout_seconds=20.0):
github_calls.append(username)
return {"2026-01-01": 1 if username == "octocat" else 2}
async def fake_fetch_gitea_activity(base_url, username, token, from_date, to_date, timeout_seconds=20.0):
gitea_calls.append(username)
return {"2026-01-01": 3 if username == "alice" else 4}
monkeypatch.setattr("app.main.fetch_github_activity", fake_fetch_github_activity)
monkeypatch.setattr("app.main.fetch_gitea_activity", fake_fetch_gitea_activity)
result = await collect_merged_activity(
settings=settings,
cache=None,
options=QueryOptions(year=None, days=1, theme="light", source="all"),
)
assert sorted(github_calls) == ["hubot", "octocat"]
assert sorted(gitea_calls) == ["alice", "bob"]
assert result.merged["2026-01-01"] == {"github": 3, "gitea": 7, "total": 10}
@pytest.mark.asyncio
async def test_fetch_source_usernames_skips_failed_accounts() -> None:
async def ok_fetch():
return {"2026-01-01": 2}
async def bad_fetch():
raise github_source.GitHubSourceError("boom")
result, stale = await _fetch_source_usernames(
None,
"github",
["octocat", "hubot"],
date(2026, 1, 1),
date(2026, 1, 1),
lambda username: ok_fetch if username == "octocat" else bad_fetch,
)
assert result == {"2026-01-01": 2}
assert stale is False
@pytest.mark.asyncio
async def test_fetch_source_usernames_does_not_swallow_programming_errors() -> None:
def broken_fetcher(username):
async def fetch():
raise TypeError("bad shape")
return fetch
with pytest.raises(TypeError, match="bad shape"):
await _fetch_source_usernames(
None,
"github",
["octocat", "hubot"],
date(2026, 1, 1),
date(2026, 1, 1),
broken_fetcher,
)
@pytest.mark.asyncio
async def test_fetch_source_usernames_keeps_cache_after_username_list_changes() -> None:
with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir:
cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=0)
def prime_fetcher(username):
async def fetch():
return {"2026-01-01": 1}
return fetch
await _fetch_source_usernames(
cache,
"github",
["octocat"],
date(2026, 1, 1),
date(2026, 1, 1),
prime_fetcher,
)
def fetch_after_change(username):
async def fetch():
if username == "octocat":
raise github_source.GitHubSourceError("down")
return {"2026-01-01": 2}
return fetch
result, stale = await _fetch_source_usernames(
cache,
"github",
["octocat", "hubot"],
date(2026, 1, 1),
date(2026, 1, 1),
fetch_after_change,
)
assert result == {"2026-01-01": 3}
assert stale is True
+9
View File
@@ -0,0 +1,9 @@
from app.settings import split_usernames
def test_split_usernames_trims_and_deduplicates() -> None:
assert split_usernames("octocat, hubot, octocat, , gitea-user") == [
"octocat",
"hubot",
"gitea-user",
]