Files
git-contribution-graph/CLAUDE.md
T
haylan d6a4f7c41f
Tests / build-dev (pull_request) Failing after 2m40s
Tests / build-prod (pull_request) Has been skipped
Tests / test (pull_request) Has been skipped
feat(ci): update Gitea workflow to include registry login and cache handling adjustments
2026-07-17 12:37:12 +02:00

258 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\SvgRenderer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SvgRendererTest extends TestCase
{
#[Test]
public function it_renders_an_svg_with_no_contributions(): void
{
$renderer = new SvgRenderer();
$svg = $renderer->render([], 'light');
$this->assertStringContainsString('<svg', $svg);
}
}
```
### TDD: Red → Green → Refactor
Always drive new features and bug fixes with this cycle. Claude writes implementation first by default — override that with explicit prompts.
**Phase 1 — Red (write a failing test first)**
```
Write a failing test for [feature description].
Do NOT write the implementation yet.
The test should fail because the class/method does not exist.
```
**Phase 2 — Green (minimal implementation)**
```
The tests are written and failing. Now implement the minimum code to make them pass. Nothing more.
```
**Phase 3 — Refactor**
```
Tests are green. Refactor the implementation for [readability / removing duplication / naming].
Run vendor/bin/phpunit after each change to confirm tests stay green.
```
**Common anti-patterns**
| Wrong prompt | Why it breaks TDD | Correct prompt |
| ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------ |
| "Write tests for this feature" | Claude implements first, then fits tests to it | "Write **failing** tests for [feature]. Stop before any implementation." |
| "Add tests and implementation" | Loses the design feedback of failing tests | Two separate prompts: Red, then Green |
| "Make the tests pass" | Encourages skipping to a green state | "Implement the minimum to make the failing tests pass." |
| Combining Red + Green in one request | No failing baseline | Always separate the two phases |
### Running tests
Prefer to use the tests in `docker compose exec graph`
```bash
# Run full suite
vendor/bin/phpunit
# Run with human-readable output
vendor/bin/phpunit --testdox
# Run a single test file
vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Run tests matching a filter
vendor/bin/phpunit --filter it_renders
# Static analysis (PHPStan level 8)
composer phpstan
```
On Windows, run via WSL (If Docker Desctop is not):
```powershell
wsl -e bash -c "cd /mnt/g/_DEV/repos/git-contribution-graph && docker compose exec graph vendor/bin/phpunit --testdox 2>&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 <ip>:<port>: 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 }}/<repo>: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<string, int> (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, 13 → 1, 46 → 2, 79 → 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.