# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Development ```bash # Install dependencies (generates composer.lock if missing — commit it) composer install # Run dev server APP_ENV=dev php -S localhost:8080 -t public # Test the graph endpoint curl "http://localhost:8080/graph.svg?theme=dark" -o graph.svg curl "http://localhost:8080/health" ``` ### Testing conventions (apply to every test file) - `declare(strict_types=1)` at the top of every test file - Test classes are `final` and extend `TestCase` - Methods: `#[Test]` attribute + `it_` prefix + `snake_case` — e.g. `it_returns_empty_when_no_contributions` - Structure: Arrange → Act → Assert, separated by blank lines; one assertion per test when possible - Use `$this->assert*()` not `self::assert*()` ```php render([], 'light'); $this->assertStringContainsString('&1" ``` ### Auto-run hook Add to `.claude/settings.json` to run PHPUnit automatically after every file edit: ```json { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "command": "vendor/bin/phpunit 2>&1 | tail -20" } ] } } ``` ## 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 - Do not use `cache-from/cache-to: type=gha` — on this instance's act_runner (v2.0.0), the internal GitHub Actions-cache-compatible proxy is not reachable from job containers (`dial tcp :: i/o timeout`), a known act_runner docker-executor networking limitation, not a workflow bug. Use `type=registry` instead (push cache blobs to `${{ env.REGISTRY }}/:buildcache*` — see both `test.yml` and `docker-publish.yml`); it requires a registry login step even for jobs that don't push the final image. **Current workflows:** | File | Trigger | Purpose | | -------------------- | ---------------- | --------------------------------------------------------- | | `docker-publish.yml` | Push tag `*.*.*` | Build & push multi-arch image to Gitea container registry | | `test.yml` | Push/PR to `main`| Build dev+prod images, run PHPUnit and PHPStan | ### Release testing with `act` Before tagging a release (anything that would trigger `docker-publish.yml`), run `test.yml` locally with [`act`](https://github.com/nektos/act) to catch pipeline failures before pushing. `test.yml` uses plain GitHub Actions syntax (no `gitea.*` contexts), so it runs under `act` unmodified. ```bash act -j test \ -P ubuntu-latest=catthehacker/ubuntu:act-latest \ --artifact-server-path /tmp/act-artifacts ``` - `-P ubuntu-latest=catthehacker/ubuntu:act-latest` — default act runner image lacks a Docker CLI, which the `test` job needs for its nested `docker run` steps - `--artifact-server-path` — required for the `upload-artifact`/`download-artifact` steps to work; without it they silently no-op **If `act` is not installed:** warn the user it's missing and how to install it (`sudo pacman -S act`, or see the repo above) — do not install it yourself or force the check. `docker-publish.yml` is out of scope for `act` (it needs real `gitea.*` context and a live registry secret); don't try to run it locally. ## Docker ### Development `docker-compose.override.yml` is picked up automatically and targets the `dev` stage (Xdebug + all deps, source mounted). ```bash # Start dev container (override applied automatically) docker compose up -d --build # Shell into the container to run commands 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 # Force cache clear docker compose exec graph rm -rf var/cache/* # View logs docker compose logs -f graph ``` **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. ### 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 Single-controller Symfony app. Two-tier cache: a 1h filesystem SVG cache in front of a SQLite raw-data store, in front of the provider APIs. The request flow: ``` 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 (Y-m-d => count) │ │ 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 contributions → level 0, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's exact 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), table `contributions (provider, date unixtime, count)`. Keyed by `(provider, date)`, upserted via `INSERT ... ON CONFLICT`. Bounds every re-fetch to a 3-day trailing window off the last stored date per provider — the root fix for the full-365-day-refetch `MaxExecutionTimeError` that used to hit on every cache miss. `CONTRIBUTIONS_RETENTION_DAYS` controls how far back rows are kept (empty = forever). **`graph:contributions:refetch` command:** manual escape hatch (`src/Command/RefetchContributionsCommand.php`) that bypasses the incremental trailing-window fetch and re-pulls a full or explicit date range. Options: `--provider=github,gitlab` (comma-separated, default all configured), `--from=YYYY-MM-DD` (default 365 days ago), `--to=YYYY-MM-DD` (default today), `--all` (shorthand for `--from=2005-01-01`). Splits the requested range into ≤365-day chunks (GitHub's GraphQL window limit; also caps GitLab pagination per call) and merges each chunk into the store as it completes, so a large `--all` run can't reintroduce the original runaway-pagination timeout. ## Environment variables | Variable | Required | Notes | | ------------------------------------------ | ---------- | -------------------------------------------- | | `APP_SECRET` | Yes | 32+ char random string | | `GITHUB_USER` / `GITHUB_TOKEN` | For GitHub | Token scope: `read:user` | | `GITLAB_USER` / `GITLAB_TOKEN` | For GitLab | Token scopes: `read_user`, `read_api` | | `GITLAB_URL` | No | Defaults to `https://gitlab.com` | | `GITEA_USER` / `GITEA_TOKEN` / `GITEA_URL` | For Gitea | Token scope: `read:user` | | `ALLOWED_HOSTS` | No | Comma-separated hostnames; empty = allow all | | `CONTRIBUTIONS_RETENTION_DAYS` | No | Days of history to keep in the SQLite store; empty = keep forever | Copy `.env` to `.env.local` for local development — `.env.local` is gitignored.