15 Commits
Author SHA1 Message Date
haylanandClaude-Bot 225c614057 test(service): refactor SvgRendererTest to follow PHPUnit best practices
- Replace setUp() factory with direct instantiation (zero-param constructor)
- Add AAA blank-line separators to all test methods
- Consolidate theme + fallback tests behind a DataProvider
- Consolidate day-of-week label tests behind a DataProvider
- Split multi-assertion tests to one assertion per test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:58:23 +02:00
haylanandClaude-Bot c205fed14b refactor(controller): extract contribution aggregation into dedicated service
Move fetchAllContributions and merge logic from GraphController into a new
ContributionAggregator service. Replace deprecated TaggedIterator with
AutowireIterator throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:58:17 +02:00
haylanandClaude-Bot 3e3a6752af chore(dev): remove broken auto-run PHPUnit hook
The PostToolUse hook called php bin/phpunit directly, which does not
work inside the Docker-based dev setup. Remove it; tests should be
run via docker compose exec graph vendor/bin/phpunit as documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:39:39 +02:00
haylanandClaude-Bot 5b07eae672 docs: update installation guide to use pre-built image
Replace the clone-and-build workflow with a docker-compose snippet
that pulls the image directly from the Gitea container registry.
Add semver tag reference and rename steps to match the new flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:39:33 +02:00
haylanandClaude-Bot 235e63bfc0 fix(config): add default empty values for optional env vars
Declare empty-string defaults for all optional env vars as Symfony
parameters so the container compiles without errors when GITHUB_USER,
GITLAB_TOKEN, GITEA_URL, etc. are not set in the environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:39:29 +02:00
haylanandClaude-Bot 92380e534a feat(docker): migrate base image from php-cli to FrankenPHP Alpine
Replace php:8.4-cli-alpine3.21 with dunglas/frankenphp:1-php8.4-alpine.
No Go toolchain required — the official image ships as a pre-compiled
Alpine binary.

- Drop docker-php-ext-install opcache (FrankenPHP enables it by default)
- Both dev and final stages run frankenphp run --config /etc/caddy/Caddyfile
- Dev stage bakes in Caddyfile.dev (no worker mode, Xdebug compatible)
- Final stage bakes in production Caddyfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:38:38 +02:00
haylanandClaude-Bot 4654a287a8 feat(app): add FrankenPHP worker script for Symfony
Boot the Symfony kernel once at startup, then loop with
frankenphp_handle_request() to handle every incoming request without
re-bootstrapping the framework. Supports MAX_REQUESTS env var for
graceful worker restarts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:38:27 +02:00
haylanandClaude-Bot 2427c07f4c feat(docker): add FrankenPHP Caddy configuration
Add production and dev Caddyfiles for FrankenPHP:
- Caddyfile: HTTP-only on :8080, auto_https off, worker mode
- Caddyfile.dev: same but without worker directive so Xdebug
  step-debugging remains functional; Caddy debug logging enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:38:23 +02:00
haylan 7c1b893e09 chore: update docker image to php 8.4 2026-05-29 23:20:53 +02:00
haylan 4993fdca00 Update .env 2026-05-29 23:04:39 +02:00
haylan c4803ea5cb fix: fixed labeling to make contaienr apper correctly in the repo 2026-05-29 22:43:53 +02:00
haylan 19bfa4cf28 Update README.md 2026-05-29 22:37:25 +02:00
haylan e70d035672 feat: change to use token 2026-05-29 19:42:10 +02:00
haylan d57320cb77 fix: changed pipline to use workflow token instead self made one 2026-05-29 19:32:36 +02:00
haylan 5feae97dd3 feat: fix manuel pipline. to create a release tag or replace exisiting release 2026-05-29 19:20:32 +02:00
13 changed files with 255 additions and 117 deletions
+1 -10
View File
@@ -1,10 +1 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"command": "php bin/phpunit 2>&1 | tail -20"
}
]
}
}
{}
+1 -1
View File
@@ -1,5 +1,5 @@
APP_ENV=dev
APP_DEBUG=0
APP_DEBUG=1
APP_SECRET=changeme_replace_with_random_32char_string
# Comma-separated list of allowed hostnames. Leave empty to allow all hosts.
+33 -15
View File
@@ -1,10 +1,10 @@
# Builds and pushes a multi-arch Docker image to the Gitea container registry
# whenever a semver tag (v*.*.*) is pushed.
# Builds and pushes a multi-arch Docker image to the Gitea container registry.
# Triggered manually via workflow_dispatch — enter an existing semver tag (e.g. 1.2.3)
# in the "Release tag" input. The workflow will fail early if the tag does not exist.
#
# One-time setup required:
# 1. Create a Gitea token with "package:write" scope.
# 2. Add it as a repository secret named GITEA_TOKEN
# (Repository → Settings → Secrets → Actions).
# Requires a repository secret REGISTRY_TOKEN — a Gitea PAT with write:package scope.
# Create it at: Settings → Applications → Generate Token (scope: write:package)
# Then add it: Repository → Settings → Secrets → Actions → REGISTRY_TOKEN
#
# After a successful run the image is available at:
# <your-gitea-host>/<owner>/<repo>:<version>
@@ -12,17 +12,33 @@
name: Docker Publish
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (semver, e.g. 1.2.3)'
required: true
type: string
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate tag exists
run: |
if ! git rev-parse "refs/tags/${{ inputs.tag }}" >/dev/null 2>&1; then
echo "Error: tag '${{ inputs.tag }}' does not exist in this repository."
exit 1
fi
git checkout "refs/tags/${{ inputs.tag }}"
# Strip the protocol from the server URL to get the registry hostname.
# e.g. https://gitea.example.com → gitea.example.com
@@ -30,17 +46,19 @@ jobs:
run: |
echo "REGISTRY=$(echo '${{ gitea.server_url }}' | sed 's|https://||;s|http://||')" >> $GITHUB_ENV
# Generates OCI-compliant tags and labels from the git tag.
# v1.2.3 → image tags: 1.2.3 / 1.2 / 1
# Generates OCI-compliant tags and labels from the provided release tag.
# 1.2.3 → image tags: 1.2.3 / 1.2 / 1
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{version}},value=${{ inputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ inputs.tag }}
type=semver,pattern={{major}},value=${{ inputs.tag }}
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
# QEMU enables emulation of arm64 on the amd64 runner.
- name: Set up QEMU
@@ -55,7 +73,7 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
+18
View File
@@ -117,6 +117,24 @@ Add to `.claude/settings.json` to run PHPUnit automatically after every file edi
}
```
## Gitea Workflows
Workflow files live in [.gitea/workflows/](.gitea/workflows/). This project uses Gitea Actions (GitHub Actions-compatible syntax).
**When editing workflow files:**
- Use `gitea.*` context variables, not `github.*` — e.g. `${{ gitea.server_url }}`, `${{ gitea.actor }}`, `${{ gitea.repository }}`
- `GITHUB_ENV` and `GITHUB_OUTPUT` are still the correct env file names (Gitea Actions re-uses these)
- Secrets are set under Repository → Settings → Secrets → Actions
- The registry hostname must be derived from `gitea.server_url` (strip the protocol prefix)
- Triggers use standard `on:` syntax; `tags: '*.*.*'` matches semver pushes without a `v` prefix
**Current workflows:**
| File | Trigger | Purpose |
|---|---|---|
| `docker-publish.yml` | Push tag `*.*.*` | Build & push multi-arch image to Gitea container registry |
## Docker
### Development
+6 -5
View File
@@ -1,10 +1,9 @@
FROM php:8.3-cli-alpine AS base
FROM dunglas/frankenphp:1-php8.4-alpine AS base
RUN apk add --no-cache \
curl \
icu-libs \
libzip \
&& docker-php-ext-install opcache
libzip
WORKDIR /app
@@ -32,11 +31,12 @@ RUN apk add --no-cache ${PHPIZE_DEPS} linux-headers \
&& docker-php-ext-enable xdebug \
&& apk del ${PHPIZE_DEPS}
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-xdebug.ini
COPY docker/frankenphp/Caddyfile.dev /etc/caddy/Caddyfile
COPY composer.json composer.lock* ./
RUN composer install --no-scripts --no-interaction --prefer-dist
EXPOSE 8080
ENV APP_ENV=dev APP_DEBUG=1
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
# ── final (prod) stage — no composer binary ────────────────────────────────────
FROM base AS final
@@ -45,6 +45,7 @@ RUN addgroup -S app && adduser -S -G app app
COPY --from=build /app/vendor /app/vendor
COPY . .
COPY docker/frankenphp/Caddyfile /etc/caddy/Caddyfile
RUN mkdir -p var/cache var/log \
&& chown -R app:app /app
@@ -54,4 +55,4 @@ USER app
EXPOSE 8080
ENV APP_ENV=prod APP_DEBUG=0
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
+33 -11
View File
@@ -6,7 +6,7 @@ A self-hosted Symfony service that merges contribution data from **GitHub**, **G
https://your-host/graph.svg?theme=dark
```
![Example graph](https://gitgraph.arthurerlich/graph.svg)
![Example graph](https://gitgraph.arthurerlich.de/graph.svg)
---
@@ -27,15 +27,36 @@ https://your-host/graph.svg?theme=dark
- Docker + Docker Compose
### 1. Clone and configure
### 1. Create a `docker-compose.yml`
```bash
git clone https://git.arthurerlich.de/haylan/git-contribution-graph.git
cd git-contribution-graph
cp .env .env.local
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:
```
Edit `.env.local` with your credentials:
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=<generate with: openssl rand -hex 16>
@@ -60,7 +81,7 @@ ALLOWED_HOSTS=
Only configure the platforms you use — unused ones are silently skipped.
### 2. Start
### 3. Start
```bash
docker compose up -d
@@ -68,10 +89,11 @@ 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
### 4. Verify
```
GET /health → {"status":"ok"}
```bash
curl http://localhost:8080/health
# {"status":"ok"}
```
---
+12
View File
@@ -1,3 +1,15 @@
parameters:
env(APP_SECRET): ''
env(ALLOWED_HOSTS): ''
env(GITHUB_USER): ''
env(GITHUB_TOKEN): ''
env(GITLAB_USER): ''
env(GITLAB_TOKEN): ''
env(GITLAB_URL): ''
env(GITEA_USER): ''
env(GITEA_TOKEN): ''
env(GITEA_URL): ''
services:
_defaults:
autowire: true
+13
View File
@@ -0,0 +1,13 @@
{
admin off
auto_https off
frankenphp {
worker /app/public/worker.php
}
}
:8080 {
root * /app/public
php_server
}
+12
View File
@@ -0,0 +1,12 @@
{
admin off
auto_https off
debug
frankenphp
}
:8080 {
root * /app/public
php_server
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload.php';
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'prod', (bool) ($_SERVER['APP_DEBUG'] ?? false));
$kernel->boot();
$handler = static function () use ($kernel): void {
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
$requestCount = 0;
while (\frankenphp_handle_request($handler)) {
if ($maxRequests > 0 && ++$requestCount >= $maxRequests) {
break;
}
}
$kernel->shutdown();
+3 -34
View File
@@ -2,11 +2,10 @@
namespace App\Controller;
use App\Service\ProviderInterface;
use App\Service\ContributionAggregator;
use App\Service\SvgRenderer;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -20,8 +19,7 @@ class GraphController
private readonly array $allowedHosts;
public function __construct(
#[TaggedIterator('app.provider')]
private readonly iterable $providers,
private readonly ContributionAggregator $aggregator,
private readonly SvgRenderer $renderer,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
@@ -52,7 +50,7 @@ class GraphController
$cacheMiss = true;
$item->expiresAfter(3600);
return $this->renderer->render($this->fetchAllContributions(), $theme);
return $this->renderer->render($this->aggregator->aggregate(), $theme);
});
$this->logger->debug('GraphController: cache ' . ($cacheMiss ? 'miss' : 'hit'), ['theme' => $theme]);
@@ -78,34 +76,5 @@ class GraphController
return new Response('{"status":"ok"}', 200, ['Content-Type' => 'application/json']);
}
/** @return array<string, int> */
private function fetchAllContributions(): array
{
$contributions = [];
/** @var ProviderInterface $provider */
foreach ($this->providers as $provider) {
if (!$provider->isConfigured()) {
continue;
}
try {
$contributions = $this->merge($contributions, $provider->fetch());
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
return $contributions;
}
/** @param array<string, int> $base @param array<string, int> $new @return array<string, int> */
private function merge(array $base, array $new): array
{
foreach ($new as $date => $count) {
$base[$date] = ($base[$date] ?? 0) + $count;
}
return $base;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ContributionAggregator
{
public function __construct(
#[AutowireIterator('app.provider')]
private readonly iterable $providers,
private readonly LoggerInterface $logger,
) {}
/** @return array<string, int> */
public function aggregate(): array
{
$contributions = [];
/** @var ProviderInterface $provider */
foreach ($this->providers as $provider) {
if (!$provider->isConfigured()) {
continue;
}
try {
foreach ($provider->fetch() as $date => $count) {
$contributions[$date] = ($contributions[$date] ?? 0) + $count;
}
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
return $contributions;
}
}
+49 -36
View File
@@ -6,65 +6,65 @@ namespace App\Tests\Unit\Service;
use App\Service\SvgRenderer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(SvgRenderer::class)]
final class SvgRendererTest extends TestCase
{
private SvgRenderer $renderer;
protected function setUp(): void
#[Test]
public function it_returns_an_svg_opening_tag(): void
{
$this->renderer = new SvgRenderer();
$svg = (new SvgRenderer())->render([]);
$this->assertStringStartsWith('<svg', $svg);
}
#[Test]
public function it_returns_a_valid_svg_element(): void
public function it_returns_a_closed_svg_element(): void
{
$svg = $this->renderer->render([]);
$svg = (new SvgRenderer())->render([]);
$this->assertStringStartsWith('<svg', $svg);
$this->assertStringEndsWith('</svg>', $svg);
}
#[Test]
public function it_includes_accessibility_attributes(): void
public function it_includes_role_img_attribute_for_accessibility(): void
{
$svg = $this->renderer->render([]);
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('role="img"', $svg);
}
#[Test]
public function it_includes_aria_label_attribute_for_accessibility(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('aria-label="Contribution graph"', $svg);
}
#[Test]
public function it_applies_dark_theme_background_color(): void
#[DataProvider('theme_background_provider')]
public function it_applies_background_color_for_theme(string $theme, string $expectedColor): void
{
$svg = $this->renderer->render([], 'dark');
$svg = (new SvgRenderer())->render([], $theme);
$this->assertStringContainsString('#0d1117', $svg);
$this->assertStringContainsString($expectedColor, $svg);
}
#[Test]
public function it_applies_light_theme_background_color(): void
public static function theme_background_provider(): iterable
{
$svg = $this->renderer->render([], 'light');
$this->assertStringContainsString('#ffffff', $svg);
}
#[Test]
public function it_falls_back_to_dark_theme_for_unknown_theme_names(): void
{
$svg = $this->renderer->render([], 'unknown');
$this->assertStringContainsString('#0d1117', $svg);
yield 'dark theme' => ['dark', '#0d1117'];
yield 'light theme' => ['light', '#ffffff'];
yield 'unknown theme' => ['unknown', '#0d1117'];
}
#[Test]
public function it_shows_zero_contributions_when_no_data_is_provided(): void
{
$svg = $this->renderer->render([]);
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('0 contributions in the last year', $svg);
}
@@ -74,7 +74,7 @@ final class SvgRendererTest extends TestCase
{
$today = (new \DateTimeImmutable('today'))->format('Y-m-d');
$svg = $this->renderer->render([$today => 1234]);
$svg = (new SvgRenderer())->render([$today => 1234]);
$this->assertStringContainsString('1,234 contributions in the last year', $svg);
}
@@ -82,28 +82,41 @@ final class SvgRendererTest extends TestCase
#[Test]
public function it_renders_all_53_week_columns(): void
{
$svg = $this->renderer->render([]);
$svg = (new SvgRenderer())->render([]);
// MARGIN_X(28) + col_52 * STEP(13) = 704
$this->assertStringContainsString('x="704"', $svg);
}
#[Test]
public function it_renders_day_of_week_labels_matching_github(): void
#[DataProvider('day_of_week_label_provider')]
public function it_renders_day_of_week_label(string $label): void
{
$svg = $this->renderer->render([]);
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('>Mon<', $svg);
$this->assertStringContainsString('>Wed<', $svg);
$this->assertStringContainsString('>Fri<', $svg);
$this->assertStringContainsString('>' . $label . '<', $svg);
}
public static function day_of_week_label_provider(): iterable
{
yield 'Monday' => ['Mon'];
yield 'Wednesday' => ['Wed'];
yield 'Friday' => ['Fri'];
}
#[Test]
public function it_renders_a_legend_with_less_and_more_labels(): void
public function it_renders_a_less_label_in_the_legend(): void
{
$svg = $this->renderer->render([]);
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('>Less<', $svg);
}
#[Test]
public function it_renders_a_more_label_in_the_legend(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('>More<', $svg);
}
@@ -113,7 +126,7 @@ final class SvgRendererTest extends TestCase
$today = (new \DateTimeImmutable('today'))->format('Y-m-d');
$yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
$svg = $this->renderer->render([$today => 3, $yesterday => 7]);
$svg = (new SvgRenderer())->render([$today => 3, $yesterday => 7]);
$this->assertStringContainsString('10 contributions in the last year', $svg);
}