# 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 ``` ![Example graph](https://gitgraph.arthurerlich.de/graph.svg) --- ## 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 `` 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= # 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 ![Contribution Graph](https://your-host/graph.svg) ![Contribution Graph](https://your-host/graph.svg?theme=light) ``` --- ## 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 (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