# 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 `
` 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 — no need to clone the repo:
```yaml
services:
graph:
image: git.arthurerlich.de/haylan/git-contribution-graph:latest
ports:
- "8080:8080"
env_file:
- .env.local
volumes:
- cache:/var/www/html/var/cache
volumes:
cache:
```
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=
```
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. Put Traefik or nginx in front of it for HTTPS.
### 4. Verify
```bash
curl http://localhost:8080/health
# {"status":"ok"}
```
---
## 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


```
---
## 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 php 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
php bin/phpunit
# Human-readable output
php bin/phpunit --testdox
# Single file
php bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Filter by name
php bin/phpunit --filter it_renders
```
---
## Architecture
```
GET /graph.svg?theme=dark|light
└─ GraphController
├─ host check (ALLOWED_HOSTS env, optional)
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
│ └─ on miss:
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar)
│ ├─ GitLabProvider → GitLab REST API (/users/:id/events, paginated)
│ └─ GiteaProvider → Gitea REST API (/users/:user/heatmap)
│ each returns array (Y-m-d => count)
│ failures are caught and logged; remaining providers still render
│ └─ merge by date (sum counts across providers)
│ └─ 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.
**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.
---
## License
MIT