haylan ac515400e0 chore(phpstan): remove PHP-Stan-Errors.md
All findings it catalogued are resolved; drop the file rather than
keep a stale error log around.
2026-07-12 23:44:32 +02:00
2026-05-30 16:29:31 +02:00
2026-05-30 14:27:58 +02:00
2026-07-12 12:43:54 +02:00
2026-07-12 12:43:54 +02:00
2026-06-04 00:44:18 +02:00

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


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) -->
![Contribution Graph](https://your-host/graph.svg)

<!-- Light theme -->
![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)

# 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-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, 13 → 1, 46 → 2, 79 → 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

S
Description
No description provided
Readme
614 KiB
2026-07-12 21:55:28 +00:00
Languages
PHP 98.6%
Dockerfile 1.4%