README had drifted from the codebase: wrong test path, a stale /health sample response, a docker-compose volume mount pointing at a path the image never writes to (with no SQLite data volume at all), a missing CONTRIBUTIONS_RETENTION_DAYS example, and an architecture diagram predating the SQLite store/refetch command. Also fixes docker-compose.prod.yml, which was missing the data volume and CONTRIBUTIONS_RETENTION_DAYS env var present in docker-compose.yml — without it, a prod deployment on this file loses contribution history on every container recreate.
297 lines
9.0 KiB
Markdown
297 lines
9.0 KiB
Markdown
# git-contribution-graph
|
||
|
||
A self-hosted Symfony service that merges contribution data from **GitHub**, **GitLab** and **Gitea** into a single GitHub-style heatmap SVG you can embed anywhere.
|
||
|
||
```
|
||
https://your-host/graph.svg?theme=dark
|
||
```
|
||
|
||

|
||
|
||
---
|
||
|
||
## Features
|
||
|
||
- **Three platforms** — GitHub, GitLab (cloud or self-hosted), Gitea (self-hosted)
|
||
- **Merged heatmap** — contributions from all sources are summed per day
|
||
- **GitHub colour palette** — exact dark/light theme tokens
|
||
- **Embeddable** — returns `image/svg+xml`, works in any `<img>` tag or Markdown
|
||
- **Cached** — responses cached for 1 hour, safe to embed in public READMEs
|
||
- **Graceful degradation** — if one platform fails, the others still render
|
||
|
||
---
|
||
|
||
## Deploy
|
||
|
||
### Requirements
|
||
|
||
- Docker + Docker Compose
|
||
|
||
### 1. Create a `docker-compose.yml`
|
||
|
||
Use the pre-built image
|
||
|
||
```yaml
|
||
services:
|
||
graph:
|
||
image: git.arthurerlich.de/haylan/git-contribution-graph:latest
|
||
ports:
|
||
- "8080:8080"
|
||
env_file:
|
||
- .env.local
|
||
volumes:
|
||
- cache:/app/var/cache/prod/pools
|
||
- logs:/app/var/log
|
||
- data:/app/var/data
|
||
|
||
volumes:
|
||
cache:
|
||
logs:
|
||
data:
|
||
```
|
||
|
||
The image is published to the Gitea container registry. Pull it manually with:
|
||
|
||
```bash
|
||
docker pull git.arthurerlich.de/haylan/git-contribution-graph:latest
|
||
```
|
||
|
||
Semver tags are published (`0`, `0.0`, `0.0.1`) alongside `latest` — see the [container registry](https://git.arthurerlich.de/haylan/-/packages/container/git-contribution-graph/latest) for all available tags.
|
||
|
||
### 2. Configure
|
||
|
||
Create a `.env.local` file next to your `docker-compose.yml`:
|
||
|
||
```dotenv
|
||
APP_SECRET=<generate with: openssl rand -hex 16>
|
||
|
||
# GitHub
|
||
GITHUB_USER=your-github-username
|
||
GITHUB_TOKEN=ghp_…
|
||
|
||
# GitLab (omit GITLAB_URL to use gitlab.com)
|
||
GITLAB_USER=your-gitlab-username
|
||
GITLAB_TOKEN=glpat-…
|
||
GITLAB_URL=
|
||
|
||
# Gitea
|
||
GITEA_USER=your-gitea-username
|
||
GITEA_TOKEN=…
|
||
GITEA_URL=https://git.example.com
|
||
|
||
# Optional: restrict to specific hostnames (comma-separated), leave empty to allow all
|
||
ALLOWED_HOSTS=
|
||
|
||
# Optional: days of contribution history to keep in the SQLite store, empty/0 = keep forever
|
||
CONTRIBUTIONS_RETENTION_DAYS=
|
||
```
|
||
|
||
Only configure the platforms you use — unused ones are silently skipped.
|
||
|
||
### 3. Start
|
||
|
||
```bash
|
||
docker compose up -d
|
||
```
|
||
|
||
The service listens on **port 8080** by default (served by FrankenPHP/Caddy, baked into the image — no separate PHP-FPM/nginx needed). Put Traefik or nginx in front of it only if you need HTTPS termination.
|
||
|
||
### 4. Verify
|
||
|
||
```bash
|
||
curl http://localhost:8080/health
|
||
```
|
||
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"providers": {
|
||
"github": { "status": "ok" },
|
||
"gitlab": { "status": "ok" }
|
||
}
|
||
}
|
||
```
|
||
|
||
Returns HTTP 503 with `"status": "degraded"` if any configured provider's probe fails (its entry then includes `error` and `message`).
|
||
|
||
---
|
||
|
||
## API
|
||
|
||
### `GET /graph.svg`
|
||
|
||
| Parameter | Required | Description |
|
||
|---|---|---|
|
||
| `theme` | ✗ | `dark` (default) or `light` |
|
||
|
||
All credentials are configured via environment variables — see [Deploy](#deploy).
|
||
|
||
---
|
||
|
||
## Embedding in a README
|
||
|
||
```markdown
|
||
<!-- Dark theme (default) -->
|
||

|
||
|
||
<!-- Light theme -->
|
||

|
||
```
|
||
|
||
---
|
||
|
||
## Token setup
|
||
|
||
### GitHub
|
||
|
||
**Fine-grained token (recommended):**
|
||
1. Go to **Settings → Developer settings → Personal access tokens → Fine-grained tokens**
|
||
2. Set **Resource owner** to your account
|
||
3. Under **Permissions → Account permissions**, set **Contribution activity** → Read-only
|
||
4. Generate and copy the token
|
||
|
||
**Classic token:** create a token with the `read:user` scope.
|
||
|
||
### GitLab
|
||
|
||
1. Go to **User Settings → Access Tokens**
|
||
2. Add a token with scopes: `read_user`, `read_api`
|
||
3. Generate and copy the token
|
||
|
||
### Gitea
|
||
|
||
1. Go to **Settings → Applications → Manage Access Tokens**
|
||
2. Add a token with permission: **user** → Read
|
||
3. Generate and copy the token
|
||
|
||
---
|
||
|
||
## Development
|
||
|
||
### Local (PHP)
|
||
|
||
```bash
|
||
# Install deps
|
||
composer install
|
||
|
||
# Run dev server
|
||
APP_ENV=dev php -S localhost:8080 -t public
|
||
|
||
# Test endpoint
|
||
curl "http://localhost:8080/graph.svg" -o graph.svg
|
||
```
|
||
|
||
### Docker (recommended)
|
||
|
||
`docker-compose.override.yml` is picked up automatically and targets the `dev` stage (Xdebug enabled, source mounted).
|
||
|
||
```bash
|
||
# Start dev container
|
||
docker compose up -d --build
|
||
|
||
# Shell into the container
|
||
docker compose exec graph sh
|
||
|
||
# Run tests inside the container
|
||
docker compose exec graph vendor/bin/phpunit
|
||
|
||
# Disable Xdebug for faster test runs
|
||
XDEBUG_MODE=off docker compose up -d
|
||
```
|
||
|
||
Xdebug listens on port **9003**. On Linux, `host.docker.internal` is resolved via `host-gateway`.
|
||
|
||
To run with production config only (no override):
|
||
|
||
```bash
|
||
docker compose -f docker-compose.yml up -d --build
|
||
```
|
||
|
||
---
|
||
|
||
## Testing
|
||
|
||
```bash
|
||
# Run full suite
|
||
vendor/bin/phpunit
|
||
|
||
# Human-readable output
|
||
vendor/bin/phpunit --testdox
|
||
|
||
# Single file
|
||
vendor/bin/phpunit tests/Unit/Service/Renderer/SvgRendererTest.php
|
||
|
||
# Filter by name
|
||
vendor/bin/phpunit --filter it_renders
|
||
```
|
||
|
||
### Static analysis
|
||
|
||
```bash
|
||
composer phpstan
|
||
```
|
||
|
||
Runs PHPStan at level 8 over `src/` and `tests/` (see `phpstan.neon`). First
|
||
run's findings are catalogued in [PHP-Stan-Errors.md](PHP-Stan-Errors.md).
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
Two-tier cache: a 1h filesystem SVG cache in front of a SQLite raw-data store, in front of the provider APIs.
|
||
|
||
```
|
||
GET /graph.svg?theme=dark|light
|
||
└─ GraphController
|
||
├─ host check (ALLOWED_HOSTS env, optional)
|
||
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
|
||
│ └─ on miss: ContributionAggregator::aggregate()
|
||
│ ├─ per configured provider: ContributionStore::latestDate($name)
|
||
│ │ → $since = latest - 3 days (trailing overlap), or null on first run
|
||
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar query, bounded by $since/$until)
|
||
│ ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page, `after`/`before` bounded)
|
||
│ ├─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap (filtered client-side by $since/$until)
|
||
│ │ each returns array<string, int> (Y-m-d => count); providers fetch concurrently
|
||
│ │ (start/resolve split); failures are caught and logged, remaining providers still render
|
||
│ ├─ ContributionStore::merge() persists fresh data per provider (SQLite upsert)
|
||
│ ├─ ContributionStore::all(sinceDays: 371) reads back the render window (source of truth)
|
||
│ ├─ ContributionStore::prune() drops rows older than CONTRIBUTIONS_RETENTION_DAYS
|
||
│ └─ SvgRenderer::render()
|
||
└─ Response: image/svg+xml, Cache-Control: public max-age=3600
|
||
```
|
||
|
||
**Provider activation:** a provider only runs when its env vars are non-empty. GitHub and GitLab require `_USER` + `_TOKEN`; Gitea additionally requires `_URL`. GitLab resolves a numeric user ID from the username via a `/api/v4/users?username=` lookup before fetching events.
|
||
|
||
**SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 → level 0, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained.
|
||
|
||
**SVG cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently.
|
||
|
||
**ContributionStore:** PDO SQLite at `var/data/contributions.db` (also a Docker volume), keyed by `(provider, date)`. Bounds every re-fetch to a trailing window off the last stored date per provider, instead of re-pulling all 365 days on every cache miss. `CONTRIBUTIONS_RETENTION_DAYS` controls how far back rows are kept (empty/0 = forever).
|
||
|
||
**Health check:** `GET /health` probes each configured provider's credentials/reachability (without fetching contribution data) and reports `ok`/`degraded`.
|
||
|
||
---
|
||
|
||
## CLI
|
||
|
||
`graph:contributions:refetch` is a manual escape hatch that bypasses the incremental trailing-window fetch and re-pulls a full or explicit date range — useful for backfilling history or recovering from a gap.
|
||
|
||
```bash
|
||
docker compose exec graph bin/console graph:contributions:refetch --all
|
||
```
|
||
|
||
| Option | Default | Description |
|
||
| ------------ | -------------------- | --------------------------------------------------------- |
|
||
| `--provider` | all configured | Comma-separated provider names to refetch, e.g. `github,gitlab` |
|
||
| `--from` | 365 days ago | Start date (`YYYY-MM-DD`) |
|
||
| `--to` | today | End date (`YYYY-MM-DD`) |
|
||
| `--all` | off | Shorthand for `--from=2005-01-01` |
|
||
|
||
Large ranges are split into ≤365-day chunks and merged into the store as each completes.
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
MIT
|