e94d2f6481
Extends the JSON response to include per-day repo names via array_agg on action.repo_name, and surfaces them in the hover tooltip below the contribution count. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
5.1 KiB
Markdown
148 lines
5.1 KiB
Markdown
# gitea-heatmap-sidecar
|
|
|
|
A tiny HTTP service that exposes daily contribution counts for an allowlisted
|
|
Gitea user — **including private repo activity** — for rendering a
|
|
GitHub-style heatmap on a public profile page.
|
|
|
|
Stock Gitea (and Forgejo) intentionally do not expose private contribution
|
|
counts to anonymous viewers. This is a sidecar workaround: read directly from
|
|
Gitea's `action` table with a read-only DB user, return per-day counts as
|
|
JSON, and let a custom profile template render the squares client-side.
|
|
|
|
## What it shows / what it doesn't
|
|
|
|
- ✅ Daily counts (the green squares)
|
|
- ✅ Hover tooltips with the date, count, and repo names
|
|
- ❌ Commit messages, branches, file content — none of that ever leaves the
|
|
database.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
[anonymous visitor] → GET /didericis (Gitea)
|
|
↓ profile.tmpl override loads
|
|
<script> fetches https://heatmap.dideric.is/heatmap/didericis.json
|
|
↓
|
|
[this service] → SELECT FROM action WHERE act_user_id=? → JSON
|
|
```
|
|
|
|
## Setup
|
|
|
|
### 1. Create a read-only Postgres user
|
|
|
|
Run `db/setup.sql` against the Gitea database as a superuser. Edit the
|
|
password first.
|
|
|
|
```bash
|
|
psql -U postgres -d gitea -f db/setup.sql
|
|
```
|
|
|
|
Only `SELECT` on `"user"` and `"action"` is granted. If Gitea ever renames
|
|
either table in a migration, the service will break loudly — that's the goal.
|
|
|
|
### 2. Build and run the sidecar
|
|
|
|
Edit `docker-compose.example.yml`, then:
|
|
|
|
```bash
|
|
docker compose -f docker-compose.example.yml up -d --build
|
|
```
|
|
|
|
Make sure the `networks` block matches your existing Gitea Docker network so
|
|
the sidecar can reach `gitea-db` by hostname.
|
|
|
|
Required env vars:
|
|
|
|
| Var | Description |
|
|
|------------------|----------------------------------------------------------|
|
|
| `DATABASE_URL` | `postgres://heatmap_ro:...@host:5432/gitea?sslmode=...` |
|
|
| `ALLOWED_USERS` | Comma-separated lowercase usernames (e.g. `didericis`) |
|
|
| `ALLOWED_ORIGIN` | CORS origin — must match Gitea's URL |
|
|
| `OP_TYPES` | Optional. Comma-separated `op_type` ints. See below. |
|
|
| `LISTEN` | Optional. Default `:8080`. |
|
|
|
|
### 3. Reverse proxy
|
|
|
|
Expose the service at a hostname Gitea's frontend can reach over HTTPS — e.g.
|
|
`heatmap.dideric.is` → `heatmap:8080`. Use the same TLS setup as Gitea
|
|
itself (Caddy/Traefik/nginx).
|
|
|
|
### 4. Install the profile template override
|
|
|
|
Copy `templates/user/profile.tmpl` from the Gitea source matching your
|
|
running version into `$GITEA_CUSTOM/templates/user/profile.tmpl`, then merge
|
|
in the snippet from `templates/profile-snippet.tmpl` near the existing
|
|
heatmap block.
|
|
|
|
Replace `HEATMAP_BASE_URL` in the snippet with your sidecar's public URL
|
|
(e.g. `https://heatmap.dideric.is`) and `didericis` with the username you're
|
|
exposing.
|
|
|
|
Restart Gitea, hit the profile page in incognito, and you should see the
|
|
heatmap populate.
|
|
|
|
## Op type reference
|
|
|
|
Gitea's `action.op_type` is an integer enum. Defaults are commits-only
|
|
(`5,9,18`), which is the closest analog to what GitHub counts as a
|
|
"contribution" in their heatmap. Common values:
|
|
|
|
| Value | Meaning |
|
|
|-------|--------------------------|
|
|
| 1 | Create repo |
|
|
| 5 | Push commits |
|
|
| 6 | Create issue |
|
|
| 7 | Create pull request |
|
|
| 9 | Push tag |
|
|
| 11 | Merge pull request |
|
|
| 18 | Mirror sync push |
|
|
| 24 | Publish release |
|
|
|
|
Set `OP_TYPES=5,6,7,9,11,18,24` for a more inclusive count.
|
|
|
|
## Endpoints
|
|
|
|
- `GET /heatmap/{username}.json` — JSON `[{"date":"YYYY-MM-DD","count":N,"repos":["name", ...]}, ...]`
|
|
for the past ~53 weeks. 1-hour cache header.
|
|
- `GET /healthz` — 200 if the DB is reachable, 503 otherwise.
|
|
|
|
## Maintenance
|
|
|
|
- **Gitea schema changes.** The query reads from `"action"` and `"user"`.
|
|
These tables have been stable for many Gitea versions. After each Gitea
|
|
upgrade, hit `/healthz` and the JSON endpoint to confirm.
|
|
- **Template drift.** The harder part. Gitea's `profile.tmpl` does change
|
|
between versions — diff the new upstream against your override on each
|
|
upgrade and re-merge the snippet.
|
|
- **Cache.** 1-hour `Cache-Control` header keeps load trivial. Drop to 5
|
|
minutes for snappier updates if needed; the underlying query is cheap
|
|
thanks to the existing index on `(user_id, act_user_id, created_unix)`
|
|
added in Gitea 1.24.
|
|
|
|
## Local dev
|
|
|
|
```bash
|
|
go run . # requires DATABASE_URL and ALLOWED_USERS in env
|
|
```
|
|
|
|
On NixOS:
|
|
|
|
```bash
|
|
nix-shell -p go_1_23 postgresql
|
|
```
|
|
|
|
## Limitations
|
|
|
|
- Postgres only. MySQL support would mean swapping `to_timestamp(...)` for
|
|
`FROM_UNIXTIME(...)` and adjusting identifier quoting — straightforward
|
|
but not done here.
|
|
- Single-tenant by allowlist. The service is meant for a personal Gitea
|
|
instance with a small known set of users opting in. Don't expose it to
|
|
arbitrary usernames; that would leak private activity counts for users
|
|
who haven't consented.
|
|
- No auth on the JSON endpoint by design — the data is intentionally public.
|
|
|
|
## License
|
|
|
|
MIT
|