krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users. contributions server sourcehut stats

Commit 1878dff520

1878dff520ee2e424b088a963c8f6417f5106ce9

parent: acbff854f2

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-11 17:34 UTC

feat: harden contribution api for production use

Layout: unified · split

README.md +7 −9
@@ -9,7 +9,7 @@ The current V1 is intentionally narrow and production-oriented:
9- polling-based ingestion 9- polling-based ingestion
10- complete `todo.sr.ht` ingestion path 10- complete `todo.sr.ht` ingestion path
11- practical `git.sr.ht` commit ingestion for tracked repositories 11- practical `git.sr.ht` commit ingestion for tracked repositories
12- API-key protection for `/api/*` 12- public read-only contribution endpoints plus API-key protection for mutating/admin routes
13- Alembic-managed schema migrations 13- Alembic-managed schema migrations
14 14
15## What It Does 15## What It Does
@@ -75,7 +75,7 @@ Tracked git repositories are persisted in the `tracked_repositories` table and s
75 75
76Environment variables: 76Environment variables:
77 77
78- `API_KEY`: required header token for all `/api/*` routes via `X-API-Key` 78- `API_KEY`: required header token for mutating/admin routes via `X-API-Key`
79- `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true` 79- `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true`
80- `SRHT_TOKEN`: bearer token for SourceHut GraphQL 80- `SRHT_TOKEN`: bearer token for SourceHut GraphQL
81- `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query` 81- `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query`
@@ -189,15 +189,13 @@ Response:
189### Contribution Calendar by Year 189### Contribution Calendar by Year
190 190
191```bash 191```bash
192curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" \ 192curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026"
193 -H "X-API-Key: replace-me"
194``` 193```
195 194
196### Contribution Calendar by Date Range 195### Contribution Calendar by Date Range
197 196
198```bash 197```bash
199curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" \ 198curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30"
200 -H "X-API-Key: replace-me"
201``` 199```
202 200
203Example response: 201Example response:
@@ -218,8 +216,7 @@ Example response:
218### Contribution Stats 216### Contribution Stats
219 217
220```bash 218```bash
221curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" \ 219curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026"
222 -H "X-API-Key: replace-me"
223``` 220```
224 221
225Example response: 222Example response:
@@ -292,7 +289,8 @@ pytest
292Covered areas: 289Covered areas:
293 290
294- health endpoint 291- health endpoint
295- API key enforcement 292- public read-only contribution endpoints
293- API key enforcement for mutating/admin routes
296- calendar aggregation 294- calendar aggregation
297- zero-filled ranges 295- zero-filled ranges
298- stats calculations 296- stats calculations
src/srht_contrib/api/routes_contributions.py +2 −2
@@ -13,7 +13,7 @@ from srht_contrib.services.srht_client import SourceHutClientError
13from srht_contrib.utils.dates import parse_date, year_bounds 13from srht_contrib.utils.dates import parse_date, year_bounds
14from srht_contrib.utils.identity import ActorIdentityResolver 14from srht_contrib.utils.identity import ActorIdentityResolver
15 15
16router = APIRouter(prefix="/api/contributions", tags=["contributions"], dependencies=[Depends(require_api_key)]) 16router = APIRouter(prefix="/api/contributions", tags=["contributions"])
17 17
18 18
19def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]: 19def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]:
@@ -63,7 +63,7 @@ def get_contribution_stats(
63 return ContributionAggregator().build_stats(db, canonical_actor, start, end) 63 return ContributionAggregator().build_stats(db, canonical_actor, start, end)
64 64
65 65
66@router.post("/poll", response_model=PollResponse) 66@router.post("/poll", response_model=PollResponse, dependencies=[Depends(require_api_key)])
67def trigger_manual_poll( 67def trigger_manual_poll(
68 actor: str, 68 actor: str,
69 poller: PollerService = Depends(get_poller), 69 poller: PollerService = Depends(get_poller),
tests/test_contributions_api.py +6 −2
@@ -6,14 +6,18 @@ from srht_contrib.main import create_app
6from srht_contrib.models import ContributionEvent 6from srht_contrib.models import ContributionEvent
7 7
8 8
9def test_api_routes_require_api_key(settings, db_engine, session_factory) -> None: 9def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None:
10 app = create_app(settings, engine=db_engine, session_factory=session_factory) 10 app = create_app(settings, engine=db_engine, session_factory=session_factory)
11 with TestClient(app) as open_client: 11 with TestClient(app) as open_client:
12 response = open_client.get("/health") 12 response = open_client.get("/health")
13 public_contributions = open_client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
14 public_stats = open_client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
13 assert response.status_code == 200 15 assert response.status_code == 200
16 assert public_contributions.status_code == 200
17 assert public_stats.status_code == 200
14 18
15 with TestClient(app) as unauthorized: 19 with TestClient(app) as unauthorized:
16 unauthorized_response = unauthorized.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") 20 unauthorized_response = unauthorized.post("/api/contributions/poll?actor=~ccleberg")
17 assert unauthorized_response.status_code == 401 21 assert unauthorized_response.status_code == 401
18 22
19 with TestClient(app) as invalid: 23 with TestClient(app) as invalid: