Reviewed-on: #16
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
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:
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 for all available tags.
2. Configure
Create a .env.local file next to your docker-compose.yml:
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
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
curl http://localhost:8080/health
{
"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.
Embedding in a README
<!-- Dark theme (default) -->

<!-- Light theme -->

Token setup
GitHub
Fine-grained token (recommended):
- Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Set Resource owner to your account
- Under Permissions → Account permissions, set Contribution activity → Read-only
- Generate and copy the token
Classic token: create a token with the read:user scope.
GitLab
- Go to User Settings → Access Tokens
- Add a token with scopes:
read_user,read_api - Generate and copy the token
Gitea
- Go to Settings → Applications → Manage Access Tokens
- Add a token with permission: user → Read
- Generate and copy the token
Development
Local (PHP)
# 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).
# 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):
docker compose -f docker-compose.yml up -d --build
Testing
# 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
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.
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.
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