From 342490035b36958e9ef8f8c55485950a26b2ca43 Mon Sep 17 00:00:00 2001 From: Haylan Date: Thu, 28 May 2026 19:35:44 +0200 Subject: [PATCH] Initialize git-contribution-graph project with Docker setup, environment configuration, and core functionality for merging contributions from GitHub, GitLab, and Gitea into an SVG heatmap. --- .env | 3 + .gitignore | 6 + Dockerfile | 41 +++++ README.md | 174 ++++++++++++++++++++ bin/console | 12 ++ composer.json | 36 +++++ config/bundles.php | 5 + config/packages/framework.yaml | 9 ++ config/routes.yaml | 5 + docker-compose.yml | 23 +++ public/index.php | 9 ++ src/Controller/GraphController.php | 119 ++++++++++++++ src/Kernel.php | 11 ++ src/Service/GitHubProvider.php | 73 +++++++++ src/Service/GitLabProvider.php | 62 +++++++ src/Service/GiteaProvider.php | 43 +++++ src/Service/SvgRenderer.php | 250 +++++++++++++++++++++++++++++ 17 files changed, 881 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 bin/console create mode 100644 composer.json create mode 100644 config/bundles.php create mode 100644 config/packages/framework.yaml create mode 100644 config/routes.yaml create mode 100644 docker-compose.yml create mode 100644 public/index.php create mode 100644 src/Controller/GraphController.php create mode 100644 src/Kernel.php create mode 100644 src/Service/GitHubProvider.php create mode 100644 src/Service/GitLabProvider.php create mode 100644 src/Service/GiteaProvider.php create mode 100644 src/Service/SvgRenderer.php diff --git a/.env b/.env new file mode 100644 index 0000000..5654ccd --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +APP_ENV=prod +APP_DEBUG=0 +APP_SECRET=changeme_replace_with_random_32char_string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0857d02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.env.local +/.env.*.local +/vendor/ +/var/ +/public/bundles/ +composer.lock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b80117d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM php:8.3-cli-alpine AS base + +# Runtime dependencies +RUN apk add --no-cache \ + curl \ + icu-libs \ + libzip \ + && docker-php-ext-install opcache + +# Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +# ── deps stage ──────────────────────────────────────────────────────────────── +FROM base AS deps +COPY composer.json composer.lock* ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-interaction \ + --optimize-autoloader \ + --prefer-dist + +# ── final stage ─────────────────────────────────────────────────────────────── +FROM base AS final + +COPY --from=deps /app/vendor /app/vendor +COPY . . + +RUN mkdir -p var/cache var/log \ + && chmod -R 777 var \ + && composer dump-autoload --optimize --no-dev + +EXPOSE 8080 + +ENV APP_ENV=prod \ + APP_DEBUG=0 + +# Symfony's built-in dev server via PHP — fine for a single-purpose container +CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8871827 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# 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?github_user=you&github_token=ghp_…&gitea_user=you&gitea_token=…&gitea_url=https://git.example.com +``` + +![Example graph](https://img.shields.io/badge/output-SVG%20heatmap-39d353?style=flat-square) + +--- + +## 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. Clone and configure + +```bash +git clone https://git.arthurerlich.de/haylan/git-contribution-graph.git +cd git-contribution-graph +cp .env .env.local +``` + +Edit `.env.local`: + +```dotenv +APP_ENV=prod +APP_DEBUG=0 +APP_SECRET= +``` + +### 2. Start + +```bash +docker compose up -d +``` + +The service listens on **port 8080** by default. Put Traefik or nginx in front of it for HTTPS. + +### 3. Health check + +``` +GET /health → {"status":"ok"} +``` + +--- + +## API + +### `GET /graph.svg` + +| Parameter | Required | Description | +|---|---|---| +| `github_user` | ✗ | GitHub username | +| `github_token` | ✗ | GitHub PAT — scope: `read:user` | +| `gitlab_user` | ✗ | GitLab username | +| `gitlab_token` | ✗ | GitLab PAT — scope: `read_user`, `read_api` | +| `gitlab_url` | ✗ | GitLab base URL (default: `https://gitlab.com`) | +| `gitea_user` | ✗ | Gitea username | +| `gitea_token` | ✗ | Gitea PAT — scope: `read:user` | +| `gitea_url` | ✗ | Gitea instance URL, e.g. `https://git.example.com` | +| `theme` | ✗ | `dark` (default) or `light` | + +All platform parameters are optional — include only the ones you use. At least one platform must be configured for a non-empty graph. + +--- + +## Embedding in a README + +### GitHub-only + +```markdown +![Contribution Graph](https://your-host/graph.svg?github_user=YOUR_USER&github_token=YOUR_TOKEN) +``` + +### GitHub + Gitea + +```markdown +![Contribution Graph](https://your-host/graph.svg?github_user=YOUR_GH_USER&github_token=GH_TOKEN&gitea_user=YOUR_GITEA_USER&gitea_token=GITEA_TOKEN&gitea_url=https://git.arthurerlich.de) +``` + +### All three platforms + +```markdown +![Contribution Graph](https://your-host/graph.svg?github_user=GH_USER&github_token=GH_TOKEN&gitlab_user=GL_USER&gitlab_token=GL_TOKEN&gitlab_url=https://gitlab.com&gitea_user=GITEA_USER&gitea_token=GITEA_TOKEN&gitea_url=https://git.arthurerlich.de) +``` + +### Dark vs Light theme + +```markdown + +![Contribution Graph](https://your-host/graph.svg?github_user=USER&github_token=TOKEN&theme=dark) + + +![Contribution Graph](https://your-host/graph.svg?github_user=USER&github_token=TOKEN&theme=light) +``` + +> **Security note:** API tokens embedded in public Markdown URLs are visible to anyone. Use read-only fine-grained tokens with minimal scopes. For private repos or sensitive setups, proxy the request server-side and keep tokens in environment variables. + +--- + +## Token setup + +### GitHub + +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 + +### 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 + +```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?gitea_user=haylan&gitea_token=YOUR_TOKEN&gitea_url=https://git.arthurerlich.de" -o graph.svg +``` + +--- + +## Architecture + +``` +GET /graph.svg + │ + ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar) + ├─ GitLabProvider → GitLab REST API (/users/:id/events) + └─ GiteaProvider → Gitea REST API (/users/:user/heatmap) + │ + merge by date (sum counts) + │ + SvgRenderer + │ + image/svg+xml (cached 1h) +``` + +--- + +## License + +MIT diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..f7ba6f7 --- /dev/null +++ b/bin/console @@ -0,0 +1,12 @@ +#!/usr/bin/env php +=8.2", + "symfony/cache": "^7.1", + "symfony/framework-bundle": "^7.1", + "symfony/http-client": "^7.1", + "symfony/runtime": "^7.1" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true, + "allow-plugins": { + "symfony/runtime": true + } + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "7.1.*" + } + } +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..49d3fb6 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,5 @@ + ['all' => true], +]; diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..4f01569 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,9 @@ +framework: + secret: '%env(APP_SECRET)%' + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + cache: + app: cache.adapter.filesystem + default_redis_provider: 'redis://localhost' diff --git a/config/routes.yaml b/config/routes.yaml new file mode 100644 index 0000000..41ef814 --- /dev/null +++ b/config/routes.yaml @@ -0,0 +1,5 @@ +controllers: + resource: + path: ../src/Controller/ + namespace: App\Controller + type: attribute diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c44917 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + graph: + build: . + container_name: git-contribution-graph + restart: unless-stopped + ports: + - "8080:8080" + environment: + APP_ENV: prod + APP_DEBUG: "0" + APP_SECRET: "${APP_SECRET}" + volumes: + - cache:/app/var/cache + - logs:/app/var/log + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + cache: + logs: diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9982c21 --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ +query->all(); + $theme = $params['theme'] ?? 'dark'; + + // Use a cache key based on the query params (minus sensitive tokens — we hash them) + $cacheKey = 'graph_' . md5(serialize($params)); + + $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($params, $theme): string { + $item->expiresAfter(3600); // cache for 1 hour + + $contributions = []; + + if (!empty($params['github_user']) && !empty($params['github_token'])) { + try { + $contributions = $this->merge( + $contributions, + $this->github->fetch($params['github_user'], $params['github_token']) + ); + } catch (\Throwable $e) { + $this->logger->warning('GitHub fetch failed: ' . $e->getMessage()); + } + } + + if (!empty($params['gitlab_user']) && !empty($params['gitlab_token'])) { + try { + $contributions = $this->merge( + $contributions, + $this->gitlab->fetch( + $params['gitlab_user'], + $params['gitlab_token'], + $params['gitlab_url'] ?? 'https://gitlab.com' + ) + ); + } catch (\Throwable $e) { + $this->logger->warning('GitLab fetch failed: ' . $e->getMessage()); + } + } + + if (!empty($params['gitea_user']) && !empty($params['gitea_token']) && !empty($params['gitea_url'])) { + try { + $contributions = $this->merge( + $contributions, + $this->gitea->fetch( + $params['gitea_user'], + $params['gitea_token'], + rtrim($params['gitea_url'], '/') + ) + ); + } catch (\Throwable $e) { + $this->logger->warning('Gitea fetch failed: ' . $e->getMessage()); + } + } + + return $this->renderer->render($contributions, $theme); + }); + + return new Response($svg, 200, [ + 'Content-Type' => 'image/svg+xml', + 'Cache-Control' => 'public, max-age=3600', + ]); + } + + #[Route('/health', name: 'health', methods: ['GET'])] + public function health(): Response + { + return new Response('{"status":"ok"}', 200, ['Content-Type' => 'application/json']); + } + + /** Sum contributions by date from two maps. */ + private function merge(array $base, array $new): array + { + foreach ($new as $date => $count) { + $base[$date] = ($base[$date] ?? 0) + $count; + } + return $base; + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..779cd1f --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,11 @@ + date (Y-m-d) => contribution count + */ + public function fetch(string $username, string $token): array + { + $from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); + $to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); + + $response = $this->client->request('POST', self::GRAPHQL_URL, [ + 'headers' => [ + 'Authorization' => "Bearer $token", + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => self::QUERY, + 'variables' => ['username' => $username, 'from' => $from, 'to' => $to], + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + throw new \RuntimeException('GitHub GraphQL error: ' . json_encode($data['errors'])); + } + + $result = []; + $weeks = $data['data']['user']['contributionsCollection']['contributionCalendar']['weeks'] ?? []; + + foreach ($weeks as $week) { + foreach ($week['contributionDays'] as $day) { + if ($day['contributionCount'] > 0) { + $result[$day['date']] = $day['contributionCount']; + } + } + } + + return $result; + } +} diff --git a/src/Service/GitLabProvider.php b/src/Service/GitLabProvider.php new file mode 100644 index 0000000..62159d6 --- /dev/null +++ b/src/Service/GitLabProvider.php @@ -0,0 +1,62 @@ + date (Y-m-d) => event count + */ + public function fetch(string $username, string $token, string $baseUrl = 'https://gitlab.com'): array + { + $baseUrl = rtrim($baseUrl, '/'); + + // Resolve numeric user ID from username + $userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [ + 'headers' => ['PRIVATE-TOKEN' => $token], + 'query' => ['username' => $username], + ]); + + $users = $userResponse->toArray(); + if (empty($users)) { + throw new \RuntimeException("GitLab: user '$username' not found on $baseUrl"); + } + $userId = $users[0]['id']; + + $result = []; + $after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d'); + $page = 1; + + do { + $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [ + 'headers' => ['PRIVATE-TOKEN' => $token], + 'query' => [ + 'after' => $after, + 'per_page' => 100, + 'page' => $page, + ], + ]); + + $events = $response->toArray(); + + foreach ($events as $event) { + $date = substr($event['created_at'], 0, 10); + $result[$date] = ($result[$date] ?? 0) + 1; + } + + $page++; + } while (count($events) === 100); + + return $result; + } +} diff --git a/src/Service/GiteaProvider.php b/src/Service/GiteaProvider.php new file mode 100644 index 0000000..e5ba7bb --- /dev/null +++ b/src/Service/GiteaProvider.php @@ -0,0 +1,43 @@ + date (Y-m-d) => contribution count + */ + public function fetch(string $username, string $token, string $baseUrl): array + { + $baseUrl = rtrim($baseUrl, '/'); + $response = $this->client->request('GET', "$baseUrl/api/v1/users/$username/heatmap", [ + 'headers' => ['Authorization' => "token $token"], + ]); + + $data = $response->toArray(); + $cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp(); + $result = []; + + foreach ($data as $entry) { + if ($entry['timestamp'] < $cutoff) { + continue; + } + $date = date('Y-m-d', $entry['timestamp']); + $result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions']; + } + + return $result; + } +} diff --git a/src/Service/SvgRenderer.php b/src/Service/SvgRenderer.php new file mode 100644 index 0000000..195a42a --- /dev/null +++ b/src/Service/SvgRenderer.php @@ -0,0 +1,250 @@ + [ + 'bg' => '#0d1117', + 'text' => '#8b949e', + 'levels' => ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'], + ], + 'light' => [ + 'bg' => '#ffffff', + 'text' => '#57606a', + 'levels' => ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'], + ], + ]; + + private const CELL = 10; // px + private const GAP = 3; // px + private const STEP = 13; // CELL + GAP + private const WEEKS = 53; + private const MARGIN_X = 28; // left margin for day labels + private const MARGIN_Y = 20; // top margin for month labels + private const PADDING = 10; // outer padding + + public function render(array $contributions, string $theme = 'dark'): string + { + $colors = self::THEMES[$theme] ?? self::THEMES['dark']; + + $today = new \DateTimeImmutable('today'); + // align grid: last column always ends on the Saturday of the current week + $endSat = $today->modify('Saturday this week'); + $start = $endSat->modify('-' . (self::WEEKS - 1) . ' weeks')->modify('Sunday'); + + $grid = $this->buildGrid($start, $today, $contributions); + $stats = $this->computeStats($contributions); + + $totalW = self::MARGIN_X + self::WEEKS * self::STEP + self::PADDING; + $totalH = self::MARGIN_Y + 7 * self::STEP + self::PADDING + 24; // +24 for legend row + + $out = ''; + $out .= sprintf( + '', + $totalW, $totalH, $totalW, $totalH + ); + + // Background + $out .= sprintf('', $totalW, $totalH, $colors['bg']); + + // Month labels + $out .= $this->renderMonthLabels($grid, $colors['text']); + + // Day-of-week labels + $out .= $this->renderDayLabels($colors['text']); + + // Cells + $out .= $this->renderCells($grid, $colors); + + // Legend row (Less → More) + $out .= $this->renderLegend($totalW, $totalH, $colors); + + // Total count + $out .= sprintf( + '%s contributions in the last year', + $totalW - self::PADDING, + $totalH - 2, + $colors['text'], + number_format($stats['total']) + ); + + $out .= ''; + + return $out; + } + + // ------------------------------------------------------------------------- + + /** + * Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int] + * or null if the date is in the future. + */ + private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array + { + $grid = []; + $current = $start; + + for ($w = 0; $w < self::WEEKS; $w++) { + $grid[$w] = []; + for ($d = 0; $d < 7; $d++) { + if ($current > $today) { + $grid[$w][$d] = null; + } else { + $date = $current->format('Y-m-d'); + $grid[$w][$d] = ['date' => $date, 'count' => $contributions[$date] ?? 0]; + } + $current = $current->modify('+1 day'); + } + } + + return $grid; + } + + /** Map a contribution count to an intensity level 0–4. */ + private function level(int $count): int + { + return match (true) { + $count === 0 => 0, + $count <= 3 => 1, + $count <= 6 => 2, + $count <= 9 => 3, + default => 4, + }; + } + + private function computeStats(array $contributions): array + { + return ['total' => array_sum($contributions)]; + } + + // ------------------------------------------------------------------------- + + private function renderMonthLabels(array $grid, string $textColor): string + { + $out = ''; + $lastMonth = -1; + + foreach ($grid as $w => $days) { + foreach ($days as $cell) { + if ($cell === null) { + continue; + } + $ts = strtotime($cell['date']); + $month = (int) date('n', $ts); + $dom = (int) date('j', $ts); + + // Print label at the first cell of a new month (only if it fits — not in week 0 col) + if ($month !== $lastMonth && ($dom <= 7 || $w === 0)) { + $x = self::MARGIN_X + $w * self::STEP; + $out .= sprintf( + '%s', + $x, + $textColor, + date('M', $ts) + ); + $lastMonth = $month; + break; // next week + } + } + } + + return $out; + } + + private function renderDayLabels(string $textColor): string + { + // Only Mon (index 1), Wed (3), Fri (5) — matching GitHub + $labels = [1 => 'Mon', 3 => 'Wed', 5 => 'Fri']; + $out = ''; + + foreach ($labels as $d => $label) { + $y = self::MARGIN_Y + $d * self::STEP + self::CELL; + $out .= sprintf( + '%s', + $y, + $textColor, + $label + ); + } + + return $out; + } + + private function renderCells(array $grid, array $colors): string + { + $out = ''; + + foreach ($grid as $w => $days) { + foreach ($days as $d => $cell) { + if ($cell === null) { + continue; + } + + $x = self::MARGIN_X + $w * self::STEP; + $y = self::MARGIN_Y + $d * self::STEP; + $color = $colors['levels'][$this->level($cell['count'])]; + + $ts = strtotime($cell['date']); + $label = $cell['count'] > 0 + ? $cell['count'] . ' contribution' . ($cell['count'] !== 1 ? 's' : '') . ' on ' . date('F j, Y', $ts) + : 'No contributions on ' . date('F j, Y', $ts); + + $out .= sprintf( + '%s', + $x, $y, + self::CELL, self::CELL, + $color, + $cell['date'], + $cell['count'], + htmlspecialchars($label, ENT_XML1) + ); + } + } + + return $out; + } + + private function renderLegend(int $totalW, int $totalH, array $colors): string + { + $y = $totalH - 14; + $out = sprintf( + 'Less', + self::MARGIN_X, + $y + self::CELL, + $colors['text'] + ); + + $startX = self::MARGIN_X + 26; + foreach ($colors['levels'] as $i => $color) { + $out .= sprintf( + '', + $startX + $i * (self::CELL + 2), + $y, + self::CELL, self::CELL, + $color + ); + } + + $moreX = $startX + count($colors['levels']) * (self::CELL + 2) + 4; + $out .= sprintf( + 'More', + $moreX, + $y + self::CELL, + $colors['text'] + ); + + return $out; + } +}