diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14472c8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitea +var/ +.env.local +.env.*.local +docker-compose.override.yml diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..fbfa43f --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -0,0 +1,70 @@ +# Builds and pushes a multi-arch Docker image to the Gitea container registry +# whenever a semver tag (v*.*.*) is pushed. +# +# 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). +# +# After a successful run the image is available at: +# //: + +name: Docker Publish + +on: + push: + tags: + - 'v*.*.*' + +jobs: + build-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Strip the protocol from the server URL to get the registry hostname. + # e.g. https://gitea.example.com → gitea.example.com + - name: Derive registry hostname + 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 + - 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}} + + # QEMU enables emulation of arm64 on the amd64 runner. + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + # BuildKit driver required for multi-platform builds and layer caching. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # Registry-based layer cache — survives between runs without a separate cache store. + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache,mode=max diff --git a/CLAUDE.md b/CLAUDE.md index 4c892cb..8eb2b8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,20 +118,49 @@ Add to `.claude/settings.json` to run PHPUnit automatically after every file edi ## Docker +### Development + +`docker-compose.override.yml` is picked up automatically and targets the `dev` stage (Xdebug + all deps, source mounted). + ```bash -# Build and run +# Start dev container (override applied automatically) docker compose up -d --build -# Force cache clear (clears the 1h filesystem cache) +# Shell into the container to run commands +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 + +# Force cache clear docker compose exec graph rm -rf var/cache/* # View logs docker compose logs -f graph ``` -There is no `composer.lock` in the repo. If you add or change dependencies, run `composer install` locally and commit the resulting lock file — without it, Docker builds resolve versions fresh each time and may produce inconsistent results. +**Xdebug:** listens on port `9003`. Configure your IDE to accept connections from Docker. On Linux `host.docker.internal` is resolved via `extra_hosts: host-gateway` in the override file. -The Dockerfile runs `composer install --no-scripts` (skipping Symfony post-install scripts) then `composer dump-autoload --optimize --no-dev` in the final stage. If `bin/console` fails with a missing class error inside the container, the most likely cause is the absent lock file causing an incomplete dependency resolution. +### Production + +Use only the base compose file to skip the dev override: + +```bash +docker compose -f docker-compose.yml up -d --build +``` + +The `final` stage runs as a non-root `app` user and contains no Composer binary. The build pipeline is: + +``` +base → deps (composer install --no-dev) + → build (copy source + dump-autoload) + → final (copy vendor + source, chown app, USER app) +``` + +There is no `composer.lock` in the repo. If you add or change dependencies, run `composer install` locally and commit the resulting lock file — without it, Docker builds resolve versions fresh each time and may produce inconsistent results. ## Architecture diff --git a/Dockerfile b/Dockerfile index b80117d..8dc4ff9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,16 @@ 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 ──────────────────────────────────────────────────────────────── +# ── deps stage (prod vendor) ─────────────────────────────────────────────────── FROM base AS deps +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY composer.json composer.lock* ./ RUN composer install \ --no-dev \ @@ -22,20 +19,39 @@ RUN composer install \ --optimize-autoloader \ --prefer-dist -# ── final stage ─────────────────────────────────────────────────────────────── +# ── build stage (generate optimised classmap with source present) ────────────── +FROM deps AS build +COPY . . +RUN composer dump-autoload --optimize --no-dev --no-interaction + +# ── dev stage (all deps + Xdebug, source is mounted at runtime) ─────────────── +FROM base AS dev +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +RUN apk add --no-cache ${PHPIZE_DEPS} linux-headers \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug \ + && apk del ${PHPIZE_DEPS} +COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-xdebug.ini +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"] + +# ── final (prod) stage — no composer binary ──────────────────────────────────── FROM base AS final -COPY --from=deps /app/vendor /app/vendor +RUN addgroup -S app && adduser -S -G app app + +COPY --from=build /app/vendor /app/vendor COPY . . RUN mkdir -p var/cache var/log \ - && chmod -R 777 var \ - && composer dump-autoload --optimize --no-dev + && chown -R app:app /app + +USER app EXPOSE 8080 +ENV APP_ENV=prod APP_DEBUG=0 -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/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..7888e8b --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,31 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + App\: + resource: '../src/' + exclude: + - '../src/Kernel.php' + + _instanceof: + App\Service\ProviderInterface: + tags: ['app.provider'] + + App\Service\GitHubProvider: + arguments: + $username: '%env(GITHUB_USER)%' + $token: '%env(GITHUB_TOKEN)%' + + App\Service\GitLabProvider: + arguments: + $username: '%env(GITLAB_USER)%' + $token: '%env(GITLAB_TOKEN)%' + $baseUrl: '%env(GITLAB_URL)%' + + App\Service\GiteaProvider: + arguments: + $username: '%env(GITEA_USER)%' + $token: '%env(GITEA_TOKEN)%' + $baseUrl: '%env(GITEA_URL)%' diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..bce0fb4 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,19 @@ +# Local development overrides — picked up automatically by `docker compose up`. +# To run with production config only: docker compose -f docker-compose.yml up +services: + graph: + build: + target: dev + volumes: + - .:/app + - /app/vendor # keeps vendor from the dev image, not your local dir + environment: + APP_ENV: dev + APP_DEBUG: "1" + XDEBUG_MODE: "${XDEBUG_MODE:-debug}" + XDEBUG_CONFIG: "client_host=host.docker.internal" + # Makes host.docker.internal resolve correctly on Linux + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "9003:9003" diff --git a/docker-compose.yml b/docker-compose.yml index 7ad3f87..30be36c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: graph: - build: . + build: + context: . + target: final container_name: git-contribution-graph restart: unless-stopped ports: diff --git a/docker/php/xdebug.ini b/docker/php/xdebug.ini new file mode 100644 index 0000000..97d92b7 --- /dev/null +++ b/docker/php/xdebug.ini @@ -0,0 +1,8 @@ +[xdebug] +; Mode is controlled by the XDEBUG_MODE env var in docker-compose.override.yml. +; Set XDEBUG_MODE=off in your shell to skip Xdebug for faster test runs. +xdebug.mode=off +xdebug.client_port=9003 +xdebug.client_host=host.docker.internal +xdebug.start_with_request=yes +xdebug.log=/tmp/xdebug.log diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index abbbac5..6fa2ebe 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -2,12 +2,11 @@ namespace App\Controller; -use App\Service\GiteaProvider; -use App\Service\GitHubProvider; -use App\Service\GitLabProvider; +use App\Service\ProviderInterface; use App\Service\SvgRenderer; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -20,30 +19,13 @@ class GraphController private readonly array $allowedHosts; public function __construct( - private readonly GitHubProvider $github, - private readonly GitLabProvider $gitlab, - private readonly GiteaProvider $gitea, + #[TaggedIterator('app.provider')] + private readonly iterable $providers, private readonly SvgRenderer $renderer, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, #[Autowire(env: 'ALLOWED_HOSTS')] string $allowedHosts = '', - #[Autowire(env: 'GITHUB_USER')] - private readonly string $githubUser = '', - #[Autowire(env: 'GITHUB_TOKEN')] - private readonly string $githubToken = '', - #[Autowire(env: 'GITLAB_USER')] - private readonly string $gitlabUser = '', - #[Autowire(env: 'GITLAB_TOKEN')] - private readonly string $gitlabToken = '', - #[Autowire(env: 'GITLAB_URL')] - private readonly string $gitlabUrl = '', - #[Autowire(env: 'GITEA_USER')] - private readonly string $giteaUser = '', - #[Autowire(env: 'GITEA_TOKEN')] - private readonly string $giteaToken = '', - #[Autowire(env: 'GITEA_URL')] - private readonly string $giteaUrl = '', ) { $this->allowedHosts = array_values(array_filter(array_map('trim', explode(',', $allowedHosts)))); } @@ -59,16 +41,13 @@ class GraphController return new Response('Forbidden', 403); } - $theme = $request->query->get('theme', 'dark'); - + $theme = $request->query->get('theme', 'dark'); $cacheKey = 'graph_' . $theme; $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme): string { $item->expiresAfter(3600); - $contributions = $this->fetchAllContributions(); - - return $this->renderer->render($contributions, $theme); + return $this->renderer->render($this->fetchAllContributions(), $theme); }); return new Response($svg, 200, [ @@ -83,51 +62,34 @@ class GraphController return new Response('{"status":"ok"}', 200, ['Content-Type' => 'application/json']); } + /** @return array */ private function fetchAllContributions(): array { $contributions = []; - if ($this->githubUser !== '' && $this->githubToken !== '') { - try { - $contributions = $this->merge($contributions, $this->github->fetch($this->githubUser, $this->githubToken)); - } catch (\Throwable $e) { - $this->logger->warning('GitHub fetch failed: ' . $e->getMessage()); + /** @var ProviderInterface $provider */ + foreach ($this->providers as $provider) { + if (!$provider->isConfigured()) { + continue; } - } - if ($this->gitlabUser !== '' && $this->gitlabToken !== '') { try { - $contributions = $this->merge($contributions, $this->gitlab->fetch( - $this->gitlabUser, - $this->gitlabToken, - $this->gitlabUrl !== '' ? $this->gitlabUrl : 'https://gitlab.com' - )); + $contributions = $this->merge($contributions, $provider->fetch()); } catch (\Throwable $e) { - $this->logger->warning('GitLab fetch failed: ' . $e->getMessage()); - } - } - - if ($this->giteaUser !== '' && $this->giteaToken !== '' && $this->giteaUrl !== '') { - try { - $contributions = $this->merge($contributions, $this->gitea->fetch( - $this->giteaUser, - $this->giteaToken, - rtrim($this->giteaUrl, '/') - )); - } catch (\Throwable $e) { - $this->logger->warning('Gitea fetch failed: ' . $e->getMessage()); + $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage())); } } return $contributions; } - /** Sum contributions by date from two maps. */ + /** @param array $base @param array $new @return array */ 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/Service/GitHubProvider.php b/src/Service/GitHubProvider.php index a206618..9c4a8f5 100644 --- a/src/Service/GitHubProvider.php +++ b/src/Service/GitHubProvider.php @@ -11,19 +11,26 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; * * Required token scopes: read:user */ -class GitHubProvider +class GitHubProvider implements ProviderInterface { private const GRAPHQL_URL = 'https://api.github.com/graphql'; public function __construct( private readonly HttpClientInterface $client, private readonly GraphQLApiClientRegistryInterface $registry, + private readonly string $username, + private readonly string $token, ) {} + public function isConfigured(): bool + { + return $this->username !== '' && $this->token !== ''; + } + /** * @return array date (Y-m-d) => contribution count */ - public function fetch(string $username, string $token): array + public function fetch(): 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'); @@ -32,7 +39,7 @@ class GitHubProvider $graphqlClient = $this->registry->get('github'); $query = $graphqlClient->buildQuery( - ['user' => ['login' => $username]], + ['user' => ['login' => $this->username]], [ 'contributionsCollection' => [ '_parameters' => ['from' => $from, 'to' => $to], @@ -49,7 +56,7 @@ class GitHubProvider // transport sends form_params, so we use Symfony HttpClient here instead. $response = $this->client->request('POST', self::GRAPHQL_URL, [ 'headers' => [ - 'Authorization' => "Bearer $token", + 'Authorization' => "Bearer {$this->token}", 'Content-Type' => 'application/json', ], 'json' => ['query' => $query], diff --git a/src/Service/GitLabProvider.php b/src/Service/GitLabProvider.php index 62159d6..2b5e6b6 100644 --- a/src/Service/GitLabProvider.php +++ b/src/Service/GitLabProvider.php @@ -10,26 +10,36 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; * Required token scopes: read_user, read_api * Works with both gitlab.com and self-hosted instances. */ -class GitLabProvider +class GitLabProvider implements ProviderInterface { - public function __construct(private readonly HttpClientInterface $client) {} + public function __construct( + private readonly HttpClientInterface $client, + private readonly string $username, + private readonly string $token, + private readonly string $baseUrl = '', + ) {} + + public function isConfigured(): bool + { + return $this->username !== '' && $this->token !== ''; + } /** * @return array date (Y-m-d) => event count */ - public function fetch(string $username, string $token, string $baseUrl = 'https://gitlab.com'): array + public function fetch(): array { - $baseUrl = rtrim($baseUrl, '/'); + $baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); // Resolve numeric user ID from username $userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [ - 'headers' => ['PRIVATE-TOKEN' => $token], - 'query' => ['username' => $username], + 'headers' => ['PRIVATE-TOKEN' => $this->token], + 'query' => ['username' => $this->username], ]); $users = $userResponse->toArray(); if (empty($users)) { - throw new \RuntimeException("GitLab: user '$username' not found on $baseUrl"); + throw new \RuntimeException("GitLab: user '{$this->username}' not found on $baseUrl"); } $userId = $users[0]['id']; @@ -39,7 +49,7 @@ class GitLabProvider do { $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [ - 'headers' => ['PRIVATE-TOKEN' => $token], + 'headers' => ['PRIVATE-TOKEN' => $this->token], 'query' => [ 'after' => $after, 'per_page' => 100, diff --git a/src/Service/GiteaProvider.php b/src/Service/GiteaProvider.php index e5ba7bb..118c5d7 100644 --- a/src/Service/GiteaProvider.php +++ b/src/Service/GiteaProvider.php @@ -12,18 +12,28 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; * * Required token scopes: read:user */ -class GiteaProvider +class GiteaProvider implements ProviderInterface { - public function __construct(private readonly HttpClientInterface $client) {} + public function __construct( + private readonly HttpClientInterface $client, + private readonly string $username, + private readonly string $token, + private readonly string $baseUrl, + ) {} + + public function isConfigured(): bool + { + return $this->username !== '' && $this->token !== '' && $this->baseUrl !== ''; + } /** * @return array date (Y-m-d) => contribution count */ - public function fetch(string $username, string $token, string $baseUrl): array + public function fetch(): array { - $baseUrl = rtrim($baseUrl, '/'); - $response = $this->client->request('GET', "$baseUrl/api/v1/users/$username/heatmap", [ - 'headers' => ['Authorization' => "token $token"], + $baseUrl = rtrim($this->baseUrl, '/'); + $response = $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [ + 'headers' => ['Authorization' => "token {$this->token}"], ]); $data = $response->toArray(); diff --git a/src/Service/ProviderInterface.php b/src/Service/ProviderInterface.php new file mode 100644 index 0000000..d58119b --- /dev/null +++ b/src/Service/ProviderInterface.php @@ -0,0 +1,13 @@ + date (Y-m-d) => contribution count */ + public function fetch(): array; + + public function isConfigured(): bool; +}