krz/hutch-stats

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

Commit 965c060dca

965c060dca284edb285cbb4e7ecd2e251fa39fc0

parent: bdbc11e860

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-11 18:19 UTC

chore: harden repo defaults for secrets, deployment, and logging

Layout: unified · split

.dockerignore added +26
@@ -0,0 +1,26 @@
1.git
2.gitignore
3.env
4.env.*
5!.env.example
6.venv/
7venv/
8__pycache__/
9*.py[cod]
10*.db
11*.sqlite
12*.sqlite3
13.pytest_cache/
14.mypy_cache/
15.ruff_cache/
16.hypothesis/
17.coverage
18.coverage.*
19htmlcov/
20build/
21dist/
22*.egg-info/
23.eggs/
24.DS_Store
25.idea/
26.vscode/
.env.example +3 −3
@@ -4,11 +4,11 @@ SRHT_TOKEN=replace-me
4TODO_SRHT_ENDPOINT=https://todo.sr.ht/query 4TODO_SRHT_ENDPOINT=https://todo.sr.ht/query
5GIT_SRHT_ENDPOINT=https://git.sr.ht/query 5GIT_SRHT_ENDPOINT=https://git.sr.ht/query
6DATABASE_URL=sqlite:///./srht_contrib.db 6DATABASE_URL=sqlite:///./srht_contrib.db
7DEFAULT_ACTOR=~ccleberg 7DEFAULT_ACTOR=~your-user
8POLL_INTERVAL_SECONDS=900 8POLL_INTERVAL_SECONDS=900
9# Optional JSON object. Example: 9# Optional JSON object. Example:
10# {"~ccleberg":["cmc@example.com","Chris Cleberg"]} 10# {"~your-user":["you@example.com","Your Name"]}
11ACTOR_ALIASES_JSON={} 11ACTOR_ALIASES_JSON={}
12# Optional JSON array. Example: 12# Optional JSON array. Example:
13# ["Hutch","~ccleberg/cleberg.net"] 13# ["your-repo","~your-user/your-site"]
14GIT_TRACKED_REPOSITORIES=[] 14GIT_TRACKED_REPOSITORIES=[]
README.md +20 −14
@@ -95,10 +95,10 @@ SRHT_TOKEN=replace-me
95TODO_SRHT_ENDPOINT=https://todo.sr.ht/query 95TODO_SRHT_ENDPOINT=https://todo.sr.ht/query
96GIT_SRHT_ENDPOINT=https://git.sr.ht/query 96GIT_SRHT_ENDPOINT=https://git.sr.ht/query
97DATABASE_URL=sqlite:///./srht_contrib.db 97DATABASE_URL=sqlite:///./srht_contrib.db
98DEFAULT_ACTOR=~ccleberg 98DEFAULT_ACTOR=~your-user
99POLL_INTERVAL_SECONDS=900 99POLL_INTERVAL_SECONDS=900
100ACTOR_ALIASES_JSON={"~ccleberg":["cmc@example.com","Chris Cleberg"]} 100ACTOR_ALIASES_JSON={"~your-user":["you@example.com","Your Name"]}
101GIT_TRACKED_REPOSITORIES=["Hutch","~ccleberg/cleberg.net"] 101GIT_TRACKED_REPOSITORIES=["your-repo","~your-user/your-site"]
102``` 102```
103 103
104## Local Run Instructions 104## Local Run Instructions
@@ -151,7 +151,7 @@ uvicorn srht_contrib.main:app --reload
151Manual polling is exposed as an API endpoint: 151Manual polling is exposed as an API endpoint:
152 152
153```bash 153```bash
154curl -X POST "http://127.0.0.1:8000/api/contributions/poll?actor=~ccleberg" \ 154curl -X POST "http://127.0.0.1:8000/api/contributions/poll?actor=~your-user" \
155 -H "X-API-Key: replace-me" 155 -H "X-API-Key: replace-me"
156``` 156```
157 157
@@ -159,7 +159,7 @@ Example response:
159 159
160```json 160```json
161{ 161{
162 "actor": "~ccleberg", 162 "actor": "~your-user",
163 "inserted_events": 3, 163 "inserted_events": 3,
164 "services": ["todo", "git"] 164 "services": ["todo", "git"]
165} 165}
@@ -170,7 +170,7 @@ Scheduled polling only runs when `ENABLE_SCHEDULER=true` and uses `DEFAULT_ACTOR
170For `git.sr.ht`, tracked repositories are configured via `GIT_TRACKED_REPOSITORIES`. Entries may be either: 170For `git.sr.ht`, tracked repositories are configured via `GIT_TRACKED_REPOSITORIES`. Entries may be either:
171 171
172- `"Hutch"` for a repository owned by `DEFAULT_ACTOR` 172- `"Hutch"` for a repository owned by `DEFAULT_ACTOR`
173- `"~ccleberg/cleberg.net"` for an explicit owner/repository pair 173- `"~your-user/your-site"` for an explicit owner/repository pair
174 174
175## API Endpoints 175## API Endpoints
176 176
@@ -189,20 +189,20 @@ 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/~your-user?year=2026"
193``` 193```
194 194
195### Contribution Calendar by Date Range 195### Contribution Calendar by Date Range
196 196
197```bash 197```bash
198curl "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/~your-user?from=2026-01-01&to=2026-03-30"
199``` 199```
200 200
201Example response: 201Example response:
202 202
203```json 203```json
204{ 204{
205 "actor": "~ccleberg", 205 "actor": "~your-user",
206 "from": "2026-01-01", 206 "from": "2026-01-01",
207 "to": "2026-03-30", 207 "to": "2026-03-30",
208 "days": [ 208 "days": [
@@ -216,14 +216,14 @@ Example response:
216### Contribution Stats 216### Contribution Stats
217 217
218```bash 218```bash
219curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" 219curl "http://127.0.0.1:8000/api/contributions/~your-user/stats?year=2026"
220``` 220```
221 221
222Example response: 222Example response:
223 223
224```json 224```json
225{ 225{
226 "actor": "~ccleberg", 226 "actor": "~your-user",
227 "from": "2026-01-01", 227 "from": "2026-01-01",
228 "to": "2026-12-31", 228 "to": "2026-12-31",
229 "total_events": 42, 229 "total_events": 42,
@@ -239,7 +239,7 @@ Example response:
239List tracked repositories: 239List tracked repositories:
240 240
241```bash 241```bash
242curl "http://127.0.0.1:8000/api/repositories?actor=~ccleberg" \ 242curl "http://127.0.0.1:8000/api/repositories?actor=~your-user" \
243 -H "X-API-Key: replace-me" 243 -H "X-API-Key: replace-me"
244``` 244```
245 245
@@ -249,7 +249,7 @@ Create a tracked repository:
249curl -X POST "http://127.0.0.1:8000/api/repositories" \ 249curl -X POST "http://127.0.0.1:8000/api/repositories" \
250 -H "X-API-Key: replace-me" \ 250 -H "X-API-Key: replace-me" \
251 -H "Content-Type: application/json" \ 251 -H "Content-Type: application/json" \
252 -d '{"actor":"~ccleberg","repo_name":"Hutch"}' 252 -d '{"actor":"~your-user","repo_name":"your-repo"}'
253``` 253```
254 254
255Get, update, and delete a tracked repository: 255Get, update, and delete a tracked repository:
@@ -261,7 +261,7 @@ curl "http://127.0.0.1:8000/api/repositories/1" \
261curl -X PATCH "http://127.0.0.1:8000/api/repositories/1" \ 261curl -X PATCH "http://127.0.0.1:8000/api/repositories/1" \
262 -H "X-API-Key: replace-me" \ 262 -H "X-API-Key: replace-me" \
263 -H "Content-Type: application/json" \ 263 -H "Content-Type: application/json" \
264 -d '{"repo_name":"~ccleberg/cleberg.net"}' 264 -d '{"repo_name":"~your-user/your-site"}'
265 265
266curl -X DELETE "http://127.0.0.1:8000/api/repositories/1" \ 266curl -X DELETE "http://127.0.0.1:8000/api/repositories/1" \
267 -H "X-API-Key: replace-me" 267 -H "X-API-Key: replace-me"
@@ -316,6 +316,12 @@ The SourceHut-specific assumptions are isolated to the service modules:
316- alias management is config-driven; there is no alias CRUD API yet 316- alias management is config-driven; there is no alias CRUD API yet
317- current deployment model is trusted-operator V1, not a public multi-tenant service 317- current deployment model is trusted-operator V1, not a public multi-tenant service
318 318
319## Deployment Notes
320
321- Add a `.dockerignore` when building container images so local secrets and SQLite files are never sent to the build context.
322- For production, prefer exposing the service behind a reverse proxy instead of publishing the application port directly to the internet.
323- Set `ENABLE_SCHEDULER=true` only for single-instance deployments where this service should own polling.
324
319## Recommended Next Steps 325## Recommended Next Steps
320 326
3211. Add alias-management APIs or seed files for stronger actor identity mapping. 3271. Add alias-management APIs or seed files for stronger actor identity mapping.
compose.yml +3 −3
@@ -2,12 +2,12 @@ services:
2 hutch-stats: 2 hutch-stats:
3 build: 3 build:
4 context: . 4 context: .
5 container_name: hutch-stats 5 container_name: ${COMPOSE_PROJECT_NAME:-hutch-stats}
6 ports: 6 ports:
7 - "8000:8000" 7 - "${HOST_PORT:-8000}:8000"
8 environment: 8 environment:
9 API_KEY: ${API_KEY} 9 API_KEY: ${API_KEY}
10 ENABLE_SCHEDULER: "true" 10 ENABLE_SCHEDULER: ${ENABLE_SCHEDULER:-false}
11 SRHT_TOKEN: ${SRHT_TOKEN} 11 SRHT_TOKEN: ${SRHT_TOKEN}
12 TODO_SRHT_ENDPOINT: ${TODO_SRHT_ENDPOINT:-https://todo.sr.ht/query} 12 TODO_SRHT_ENDPOINT: ${TODO_SRHT_ENDPOINT:-https://todo.sr.ht/query}
13 GIT_SRHT_ENDPOINT: ${GIT_SRHT_ENDPOINT:-https://git.sr.ht/query} 13 GIT_SRHT_ENDPOINT: ${GIT_SRHT_ENDPOINT:-https://git.sr.ht/query}
src/srht_contrib/services/srht_client.py +16 −7
@@ -13,6 +13,12 @@ class SourceHutClientError(RuntimeError):
13 """Raised when a SourceHut GraphQL request fails.""" 13 """Raised when a SourceHut GraphQL request fails."""
14 14
15 15
16def _graphql_error_summary(errors: Any) -> str:
17 if not isinstance(errors, list):
18 return "unexpected error payload"
19 return f"{len(errors)} GraphQL error(s)"
20
21
16class SourceHutGraphQLClient: 22class SourceHutGraphQLClient:
17 def __init__( 23 def __init__(
18 self, 24 self,
@@ -42,20 +48,16 @@ class SourceHutGraphQLClient:
42 response.raise_for_status() 48 response.raise_for_status()
43 body = response.json() 49 body = response.json()
44 except httpx.HTTPStatusError as exc: 50 except httpx.HTTPStatusError as exc:
45 response_text = exc.response.text[:500]
46 logger.warning( 51 logger.warning(
47 "SourceHut HTTP failure from %s on attempt %s/%s: %s %s", 52 "SourceHut HTTP failure from %s on attempt %s/%s: status=%s",
48 self.endpoint, 53 self.endpoint,
49 attempt, 54 attempt,
50 attempts, 55 attempts,
51 exc.response.status_code, 56 exc.response.status_code,
52 response_text,
53 ) 57 )
54 if exc.response.status_code >= 500 and attempt < attempts: 58 if exc.response.status_code >= 500 and attempt < attempts:
55 continue 59 continue
56 raise SourceHutClientError( 60 raise SourceHutClientError(f"HTTP error from SourceHut: {exc.response.status_code}") from exc
57 f"HTTP error from SourceHut: {exc.response.status_code} {response_text}".strip()
58 ) from exc
59 except httpx.HTTPError as exc: 61 except httpx.HTTPError as exc:
60 logger.warning( 62 logger.warning(
61 "SourceHut network failure from %s on attempt %s/%s", 63 "SourceHut network failure from %s on attempt %s/%s",
@@ -68,7 +70,14 @@ class SourceHutGraphQLClient:
68 raise SourceHutClientError("Network error while contacting SourceHut") from exc 70 raise SourceHutClientError("Network error while contacting SourceHut") from exc
69 71
70 if "errors" in body: 72 if "errors" in body:
71 raise SourceHutClientError(f"GraphQL errors returned by SourceHut: {body['errors']}") 73 logger.warning(
74 "SourceHut GraphQL failure from %s: %s",
75 self.endpoint,
76 _graphql_error_summary(body["errors"]),
77 )
78 raise SourceHutClientError(
79 f"GraphQL errors returned by SourceHut: {_graphql_error_summary(body['errors'])}"
80 )
72 return body.get("data", {}) 81 return body.get("data", {})
73 82
74 raise SourceHutClientError("SourceHut request exhausted retries") 83 raise SourceHutClientError("SourceHut request exhausted retries")