krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users.

clone: git clone https://gitbay.org/krz/hutch-stats.git

965c060dca284edb285cbb4e7ecd2e251fa39fc0

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-11T18:19:37Z

chore: harden repo defaults for secrets, deployment, and logging
 .dockerignore                            | 26 ++++++++++++++++++++++++
 .env.example                             |  6 +++---
 README.md                                | 34 +++++++++++++++++++-------------
 compose.yml                              |  6 +++---
 src/srht_contrib/services/srht_client.py | 23 ++++++++++++++-------
 5 files changed, 68 insertions(+), 27 deletions(-)

diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c53d12d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+.git
+.gitignore
+.env
+.env.*
+!.env.example
+.venv/
+venv/
+__pycache__/
+*.py[cod]
+*.db
+*.sqlite
+*.sqlite3
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.hypothesis/
+.coverage
+.coverage.*
+htmlcov/
+build/
+dist/
+*.egg-info/
+.eggs/
+.DS_Store
+.idea/
+.vscode/
diff --git a/.env.example b/.env.example
index 1dd57f4..95d9645 100644
--- a/.env.example
+++ b/.env.example
@@ -4,11 +4,11 @@ SRHT_TOKEN=replace-me
 TODO_SRHT_ENDPOINT=https://todo.sr.ht/query
 GIT_SRHT_ENDPOINT=https://git.sr.ht/query
 DATABASE_URL=sqlite:///./srht_contrib.db
-DEFAULT_ACTOR=~ccleberg
+DEFAULT_ACTOR=~your-user
 POLL_INTERVAL_SECONDS=900
 # Optional JSON object. Example:
-# {"~ccleberg":["cmc@example.com","Chris Cleberg"]}
+# {"~your-user":["you@example.com","Your Name"]}
 ACTOR_ALIASES_JSON={}
 # Optional JSON array. Example:
-# ["Hutch","~ccleberg/cleberg.net"]
+# ["your-repo","~your-user/your-site"]
 GIT_TRACKED_REPOSITORIES=[]
diff --git a/README.md b/README.md
index b05e71a..97c9b43 100644
--- a/README.md
+++ b/README.md
@@ -95,10 +95,10 @@ SRHT_TOKEN=replace-me
 TODO_SRHT_ENDPOINT=https://todo.sr.ht/query
 GIT_SRHT_ENDPOINT=https://git.sr.ht/query
 DATABASE_URL=sqlite:///./srht_contrib.db
-DEFAULT_ACTOR=~ccleberg
+DEFAULT_ACTOR=~your-user
 POLL_INTERVAL_SECONDS=900
-ACTOR_ALIASES_JSON={"~ccleberg":["cmc@example.com","Chris Cleberg"]}
-GIT_TRACKED_REPOSITORIES=["Hutch","~ccleberg/cleberg.net"]
+ACTOR_ALIASES_JSON={"~your-user":["you@example.com","Your Name"]}
+GIT_TRACKED_REPOSITORIES=["your-repo","~your-user/your-site"]
 ```
 
 ## Local Run Instructions
@@ -151,7 +151,7 @@ uvicorn srht_contrib.main:app --reload
 Manual polling is exposed as an API endpoint:
 
 ```bash
-curl -X POST "http://127.0.0.1:8000/api/contributions/poll?actor=~ccleberg" \
+curl -X POST "http://127.0.0.1:8000/api/contributions/poll?actor=~your-user" \
   -H "X-API-Key: replace-me"
 ```
 
@@ -159,7 +159,7 @@ Example response:
 
 ```json
 {
-  "actor": "~ccleberg",
+  "actor": "~your-user",
   "inserted_events": 3,
   "services": ["todo", "git"]
 }
@@ -170,7 +170,7 @@ Scheduled polling only runs when `ENABLE_SCHEDULER=true` and uses `DEFAULT_ACTOR
 For `git.sr.ht`, tracked repositories are configured via `GIT_TRACKED_REPOSITORIES`. Entries may be either:
 
 - `"Hutch"` for a repository owned by `DEFAULT_ACTOR`
-- `"~ccleberg/cleberg.net"` for an explicit owner/repository pair
+- `"~your-user/your-site"` for an explicit owner/repository pair
 
 ## API Endpoints
 
@@ -189,20 +189,20 @@ Response:
 ### Contribution Calendar by Year
 
 ```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026"
+curl "http://127.0.0.1:8000/api/contributions/~your-user?year=2026"
 ```
 
 ### Contribution Calendar by Date Range
 
 ```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30"
+curl "http://127.0.0.1:8000/api/contributions/~your-user?from=2026-01-01&to=2026-03-30"
 ```
 
 Example response:
 
 ```json
 {
-  "actor": "~ccleberg",
+  "actor": "~your-user",
   "from": "2026-01-01",
   "to": "2026-03-30",
   "days": [
@@ -216,14 +216,14 @@ Example response:
 ### Contribution Stats
 
 ```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026"
+curl "http://127.0.0.1:8000/api/contributions/~your-user/stats?year=2026"
 ```
 
 Example response:
 
 ```json
 {
-  "actor": "~ccleberg",
+  "actor": "~your-user",
   "from": "2026-01-01",
   "to": "2026-12-31",
   "total_events": 42,
@@ -239,7 +239,7 @@ Example response:
 List tracked repositories:
 
 ```bash
-curl "http://127.0.0.1:8000/api/repositories?actor=~ccleberg" \
+curl "http://127.0.0.1:8000/api/repositories?actor=~your-user" \
   -H "X-API-Key: replace-me"
 ```
 
@@ -249,7 +249,7 @@ Create a tracked repository:
 curl -X POST "http://127.0.0.1:8000/api/repositories" \
   -H "X-API-Key: replace-me" \
   -H "Content-Type: application/json" \
-  -d '{"actor":"~ccleberg","repo_name":"Hutch"}'
+  -d '{"actor":"~your-user","repo_name":"your-repo"}'
 ```
 
 Get, update, and delete a tracked repository:
@@ -261,7 +261,7 @@ curl "http://127.0.0.1:8000/api/repositories/1" \
 curl -X PATCH "http://127.0.0.1:8000/api/repositories/1" \
   -H "X-API-Key: replace-me" \
   -H "Content-Type: application/json" \
-  -d '{"repo_name":"~ccleberg/cleberg.net"}'
+  -d '{"repo_name":"~your-user/your-site"}'
 
 curl -X DELETE "http://127.0.0.1:8000/api/repositories/1" \
   -H "X-API-Key: replace-me"
@@ -316,6 +316,12 @@ The SourceHut-specific assumptions are isolated to the service modules:
 - alias management is config-driven; there is no alias CRUD API yet
 - current deployment model is trusted-operator V1, not a public multi-tenant service
 
+## Deployment Notes
+
+- Add a `.dockerignore` when building container images so local secrets and SQLite files are never sent to the build context.
+- For production, prefer exposing the service behind a reverse proxy instead of publishing the application port directly to the internet.
+- Set `ENABLE_SCHEDULER=true` only for single-instance deployments where this service should own polling.
+
 ## Recommended Next Steps
 
 1. Add alias-management APIs or seed files for stronger actor identity mapping.
diff --git a/compose.yml b/compose.yml
index 90d10e9..3ec0560 100644
--- a/compose.yml
+++ b/compose.yml
@@ -2,12 +2,12 @@ services:
   hutch-stats:
     build:
       context: .
-    container_name: hutch-stats
+    container_name: ${COMPOSE_PROJECT_NAME:-hutch-stats}
     ports:
-      - "8000:8000"
+      - "${HOST_PORT:-8000}:8000"
     environment:
       API_KEY: ${API_KEY}
-      ENABLE_SCHEDULER: "true"
+      ENABLE_SCHEDULER: ${ENABLE_SCHEDULER:-false}
       SRHT_TOKEN: ${SRHT_TOKEN}
       TODO_SRHT_ENDPOINT: ${TODO_SRHT_ENDPOINT:-https://todo.sr.ht/query}
       GIT_SRHT_ENDPOINT: ${GIT_SRHT_ENDPOINT:-https://git.sr.ht/query}
diff --git a/src/srht_contrib/services/srht_client.py b/src/srht_contrib/services/srht_client.py
index c8e2649..ee09bff 100644
--- a/src/srht_contrib/services/srht_client.py
+++ b/src/srht_contrib/services/srht_client.py
@@ -13,6 +13,12 @@ class SourceHutClientError(RuntimeError):
     """Raised when a SourceHut GraphQL request fails."""
 
 
+def _graphql_error_summary(errors: Any) -> str:
+    if not isinstance(errors, list):
+        return "unexpected error payload"
+    return f"{len(errors)} GraphQL error(s)"
+
+
 class SourceHutGraphQLClient:
     def __init__(
         self,
@@ -42,20 +48,16 @@ class SourceHutGraphQLClient:
                 response.raise_for_status()
                 body = response.json()
             except httpx.HTTPStatusError as exc:
-                response_text = exc.response.text[:500]
                 logger.warning(
-                    "SourceHut HTTP failure from %s on attempt %s/%s: %s %s",
+                    "SourceHut HTTP failure from %s on attempt %s/%s: status=%s",
                     self.endpoint,
                     attempt,
                     attempts,
                     exc.response.status_code,
-                    response_text,
                 )
                 if exc.response.status_code >= 500 and attempt < attempts:
                     continue
-                raise SourceHutClientError(
-                    f"HTTP error from SourceHut: {exc.response.status_code} {response_text}".strip()
-                ) from exc
+                raise SourceHutClientError(f"HTTP error from SourceHut: {exc.response.status_code}") from exc
             except httpx.HTTPError as exc:
                 logger.warning(
                     "SourceHut network failure from %s on attempt %s/%s",
@@ -68,7 +70,14 @@ class SourceHutGraphQLClient:
                 raise SourceHutClientError("Network error while contacting SourceHut") from exc
 
             if "errors" in body:
-                raise SourceHutClientError(f"GraphQL errors returned by SourceHut: {body['errors']}")
+                logger.warning(
+                    "SourceHut GraphQL failure from %s: %s",
+                    self.endpoint,
+                    _graphql_error_summary(body["errors"]),
+                )
+                raise SourceHutClientError(
+                    f"GraphQL errors returned by SourceHut: {_graphql_error_summary(body['errors'])}"
+                )
             return body.get("data", {})
 
         raise SourceHutClientError("SourceHut request exhausted retries")