From 406da07397a87bfe950341aa4b31a09699671921 Mon Sep 17 00:00:00 2001 From: Haylan Date: Thu, 2 Jul 2026 07:30:01 +0200 Subject: [PATCH 01/42] chore(todod): proposal plane for sotaged the history --- TODO.md | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0da9570 --- /dev/null +++ b/TODO.md @@ -0,0 +1,133 @@ +# TODO: persist contribution history in SQLite + audit cleanup + +Backend-only work. Follow Red → Green → Refactor per `CLAUDE.md` for every +step that adds behavior (store, retention, provider `$since` handling). +Order matters — later steps assume earlier ones are done. + +## 1. Audit cleanup + +- [ ] **Remove `eightpoints/guzzle-bundle` + `idci/graphql-client-bundle`.** + They exist only to build one near-static GitHub GraphQL query string + (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already + bypassed (comment at `GitHubProvider.php:76-77`). Replace + `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain + `sprintf`/heredoc query string. Delete the two packages from + `composer.json`, `config/bundles.php`, and delete + `config/packages/eight_points_guzzle.yaml` + + `config/packages/idci_graphql_client.yaml`. Run `composer update` and + commit the regenerated `composer.lock`. +- [ ] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, + 53) and `GiteaProvider.php` (lines 42, 54) each call + `rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in + `fetch()`. Compute it once as a `private readonly string $baseUrl` in + the constructor instead. + +## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4) + +- [ ] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the + pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and + `_instanceof: App\Service\ProviderInterface`, left over from the + `Service/` → `Provider/`+`Renderer/` namespace reorg. Update all of + these to `App\Service\Provider\...`. Currently these bindings silently + no-op, and scalar constructor args can't autowire without them — any + edit to these constructors (steps 1, 4) needs this fixed first, or the + container fails to compile. + +## 3. `ContributionStore` (SQLite via native PDO) + +- [ ] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at + `%kernel.project_dir%/var/data/contributions.db` (configurable + constructor arg), table created lazily: + ```sql + CREATE TABLE IF NOT EXISTS contributions ( + provider TEXT NOT NULL, + date TEXT NOT NULL, + count INTEGER NOT NULL, + PRIMARY KEY (provider, date) + ); + ``` +- [ ] `latestDate(string $provider): ?string` — `SELECT MAX(date) WHERE provider = ?`. +- [ ] `merge(string $provider, array $dateCounts): void` — upsert via + `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`. +- [ ] `all(string $provider, ?int $sinceDays = null): array` — `date => count`; + `null` returns full history (no arbitrary cutoff), a value filters to the + last N days. +- [ ] `prune(): void` — `DELETE FROM contributions WHERE date < ?` using + the configured retention window; no-ops if retention is unset/0. +- [ ] Constructor takes `?int $retentionDays` bound from new env var + `CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). +- [ ] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store & + retrieve, upsert overwrites existing date, `sinceDays` filtering, + `prune()` no-ops when retention unset, `prune()` deletes rows older + than the window when set. + +## 4. Wire `$since` through providers + +- [ ] `ProviderInterface::fetch()` → `fetch(?\DateTimeImmutable $since = null): array`. +- [ ] `GitHubProvider::fetch()` — use `$since ?? new \DateTimeImmutable('-365 days')` + for the GraphQL `from` param. +- [ ] `GitLabProvider::fetch()` — same, feeds the `after` query param + (this is what actually shrinks the pagination loop). +- [ ] `GiteaProvider::fetch()` — same, feeds the `$cutoff` timestamp filter. +- [ ] Update `GitHubProviderTest.php` (drop `GraphQLApiClientRegistryInterface` + stub setup entirely per step 1), `GitLabProviderTest.php`, + `GiteaProviderTest.php` — add a case asserting a passed `$since` narrows + the request window. + +## 5. Wire `ContributionStore` into `ContributionAggregator` + +- [ ] Inject `ContributionStore` into `ContributionAggregator`. +- [ ] Per configured provider: `$since = $store->latestDate($name)` → if + set, `(new \DateTimeImmutable($latest))->modify('-3 days')` (3-day + overlap for late corrections), else `null` (first run). +- [ ] `$fresh = $provider->fetch($since)`, `$store->merge($name, $fresh)`, + then read back `$store->all($name, sinceDays: 371)` for the render + window (53 weeks × 7 days) and merge into the returned array. +- [ ] Call `$store->prune()` once per `aggregate()` call, after all + providers have merged. +- [ ] Keep the existing try/catch-and-log-per-provider behavior — a + provider failure leaves its DB history stale, doesn't break the render. +- [ ] Update `ContributionAggregatorTest.php` with store-interaction and + prune-call cases. + +## 6. Docker / env + +- [ ] `docker-compose.yml` — add a `data` named volume mounted at + `/app/var/data` (same pattern as `cache`/`logs`), and pass through + `CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`. +- [ ] `Dockerfile` — add `var/data` to the `mkdir -p` in the `final` stage + alongside `var/cache/prod/pools var/log`, owned by `app`. +- [ ] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default), + same style as the existing `ALLOWED_HOSTS` comment. + +## 7. PHPStan + +- [ ] `composer require --dev phpstan/phpstan` (plain PHPStan — no + Symfony extension needed for this app's size). +- [ ] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`. +- [ ] Add composer script `"phpstan": "phpstan analyse"`. +- [ ] Fix whatever level-8 flags on first run. + +## 8. `CLAUDE.md` update + +- [ ] Architecture section: add the `ContributionStore` (SQLite) tier + between providers and the renderer; note the two-tier cache (1h SVG + cache → SQLite raw-data store → provider APIs). +- [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`. +- [ ] Development section: add `composer phpstan` next to the existing + `vendor/bin/phpunit` commands. +- [ ] Remove any remaining mentions of the two deleted bundles. + +## Verification + +- `vendor/bin/phpunit --testdox` green after every step. +- `composer phpstan` clean at level 8. +- `composer install` succeeds with no dangling references to the removed bundles. +- `docker compose up -d --build`; clear the FS cache + (`docker compose exec graph rm -rf var/cache/*`); hit + `/graph.svg?theme=dark` twice; check `docker compose logs -f graph` shows + a narrower `since` window on the second fetch. +- Set `CONTRIBUTIONS_RETENTION_DAYS=30`; confirm rows older than 30 days + are gone after the next refresh + (`sqlite3 var/data/contributions.db "SELECT date FROM contributions ORDER BY date LIMIT 1"`). +- `curl localhost:8080/health` still reports all configured providers healthy. -- 2.54.0 From 6d6680ca37f9a76c1ac8a682e4aaa619ddee0c5c Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:30:57 +0200 Subject: [PATCH 02/42] fix(di): correct provider service FQCNs in services.yaml Providers were moved into the App\Service\Provider namespace, but services.yaml still tagged/configured them under the old App\Service FQCNs, so the _instanceof conditional never matched and the container failed to compile. --- config/services.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 138555e..d381730 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,21 +22,21 @@ services: - '../src/Kernel.php' _instanceof: - App\Service\ProviderInterface: + App\Service\Provider\ProviderInterface: tags: ['app.provider'] - App\Service\GitHubProvider: + App\Service\Provider\GitHubProvider: arguments: $username: '%env(GITHUB_USER)%' $token: '%env(GITHUB_TOKEN)%' - App\Service\GitLabProvider: + App\Service\Provider\GitLabProvider: arguments: $username: '%env(GITLAB_USER)%' $token: '%env(GITLAB_TOKEN)%' $baseUrl: '%env(GITLAB_URL)%' - App\Service\GiteaProvider: + App\Service\Provider\GiteaProvider: arguments: $username: '%env(GITEA_USER)%' $token: '%env(GITEA_TOKEN)%' -- 2.54.0 From 09c0be8312f52fbd00efba2c6c5111a001da511b Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:03 +0200 Subject: [PATCH 03/42] config: cap http_client timeout and max_duration Per-request bounds so a single slow/hung provider can't stall the whole request indefinitely once fetching happens concurrently. --- config/packages/framework.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 4f01569..bf8d174 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -7,3 +7,7 @@ framework: cache: app: cache.adapter.filesystem default_redis_provider: 'redis://localhost' + http_client: + default_options: + timeout: 10 # connect + wait-for-first-byte cap per request + max_duration: 15 # hard cap on total request duration -- 2.54.0 From 11bec43bbe530dd5f2dbe44d93f241e5bdf8513b Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:09 +0200 Subject: [PATCH 04/42] refactor(provider): split fetch() into startFetch()/resolveFetch() Splits the provider contract into a non-blocking startFetch() that fires the HTTP request(s) and a resolveFetch() that reads the result, so callers can fire every provider's request before blocking on any of them. Symfony HttpClient is already async under the hood - request() doesn't block until you read the response - so this needs no new dependency. --- src/Service/Provider/ProviderInterface.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Service/Provider/ProviderInterface.php b/src/Service/Provider/ProviderInterface.php index 015b559..c8203f8 100644 --- a/src/Service/Provider/ProviderInterface.php +++ b/src/Service/Provider/ProviderInterface.php @@ -6,8 +6,11 @@ namespace App\Service\Provider; interface ProviderInterface { + /** Fire the HTTP request(s) without blocking; returns an opaque handle for resolveFetch(). */ + public function startFetch(): mixed; + /** @return array date (Y-m-d) => contribution count */ - public function fetch(): array; + public function resolveFetch(mixed $handle): array; public function isConfigured(): bool; -- 2.54.0 From 9f3c54afd1c616db3aafef3bc73d0c3a60e6df18 Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:15 +0200 Subject: [PATCH 05/42] refactor(github): make GitHubProvider fetch non-blocking startFetch() fires the GraphQL POST and returns the response without reading it; resolveFetch() does the .toArray()/parse/log work that used to happen inline right after the request. --- src/Service/Provider/GitHubProvider.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Service/Provider/GitHubProvider.php b/src/Service/Provider/GitHubProvider.php index 55f71a3..265b530 100644 --- a/src/Service/Provider/GitHubProvider.php +++ b/src/Service/Provider/GitHubProvider.php @@ -9,6 +9,7 @@ use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; /** * Fetches the last 365 days of contributions from the GitHub GraphQL API. @@ -46,10 +47,7 @@ final class GitHubProvider implements ProviderInterface ])->getContent(); } - /** - * @return array date (Y-m-d) => contribution count - */ - public function fetch(): array + public function startFetch(): ResponseInterface { $this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]); @@ -75,15 +73,24 @@ final class GitHubProvider implements ProviderInterface // GitHub's GraphQL API requires application/json — the bundle's built-in // transport sends form_params, so we use Symfony HttpClient here instead. - $response = $this->client->request('POST', self::GRAPHQL_URL, [ + // request() doesn't block; the response is read in resolveFetch() so + // multiple providers' requests can be in flight at once. + return $this->client->request('POST', self::GRAPHQL_URL, [ 'headers' => [ 'Authorization' => "Bearer {$this->token}", 'Content-Type' => 'application/json', ], 'json' => ['query' => $query], ]); + } - $data = $response->toArray(); + /** + * @return array date (Y-m-d) => contribution count + */ + public function resolveFetch(mixed $handle): array + { + /** @var ResponseInterface $handle */ + $data = $handle->toArray(); if (isset($data['errors'])) { throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors'])); -- 2.54.0 From 596260bb852d5920bced1ebdf1ebece6f14942df Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:21 +0200 Subject: [PATCH 06/42] refactor(gitea): make GiteaProvider fetch non-blocking startFetch() fires the heatmap GET and returns the response without reading it; resolveFetch() does the .toArray()/parse/log work. --- src/Service/Provider/GiteaProvider.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Service/Provider/GiteaProvider.php b/src/Service/Provider/GiteaProvider.php index 411093d..050b319 100644 --- a/src/Service/Provider/GiteaProvider.php +++ b/src/Service/Provider/GiteaProvider.php @@ -6,6 +6,7 @@ namespace App\Service\Provider; use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; /** * Fetches contribution data from the Gitea heatmap endpoint. @@ -46,20 +47,24 @@ final class GiteaProvider implements ProviderInterface ])->getContent(); } - /** - * @return array date (Y-m-d) => contribution count - */ - public function fetch(): array + public function startFetch(): ResponseInterface { $baseUrl = rtrim($this->baseUrl, '/'); $this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]); - $response = $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [ + return $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [ 'headers' => ['Authorization' => "token {$this->token}"], ]); + } - $data = $response->toArray(); + /** + * @return array date (Y-m-d) => contribution count + */ + public function resolveFetch(mixed $handle): array + { + /** @var ResponseInterface $handle */ + $data = $handle->toArray(); $cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp(); $result = []; -- 2.54.0 From 9b631f47779a413b9ce5c7c1f79286aa9a3b4fb4 Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:30 +0200 Subject: [PATCH 07/42] refactor(gitlab): make GitLabProvider fetch non-blocking startFetch() resolves the user id and fires the first events page, returning a handle without reading it; resolveFetch() continues the existing do/while pagination starting from that first response. Pagination itself stays sequential within this provider since each page depends on the previous one - parallelism here only spans across providers, not within GitLab's own pages. --- src/Service/Provider/GitLabProvider.php | 52 ++++++++++++++++++------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/Service/Provider/GitLabProvider.php b/src/Service/Provider/GitLabProvider.php index cc03990..3ddf9fd 100644 --- a/src/Service/Provider/GitLabProvider.php +++ b/src/Service/Provider/GitLabProvider.php @@ -7,6 +7,7 @@ namespace App\Service\Provider; use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; /** * Fetches the last 365 days of push/merge events from the GitLab REST API. @@ -46,9 +47,14 @@ final class GitLabProvider implements ProviderInterface } /** - * @return array date (Y-m-d) => event count + * @return array{baseUrl: string, userId: int, after: string, page: int, response: ResponseInterface} + * + * ponytail: the user-id lookup and event pagination stay sequential within + * this one provider (each page depends on the previous). Parallelism here + * only spans across providers; revisit only if GitLab pagination itself + * becomes the bottleneck. */ - public function fetch(): array + public function startFetch(): array { $baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); @@ -64,21 +70,30 @@ final class GitLabProvider implements ProviderInterface throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $baseUrl"); } $userId = $users[0]['id']; + $after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d'); + + $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [ + 'headers' => ['PRIVATE-TOKEN' => $this->token], + 'query' => [ + 'after' => $after, + 'per_page' => 100, + 'page' => 1, + ], + ]); + + return ['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response]; + } + + /** + * @return array date (Y-m-d) => event count + */ + public function resolveFetch(mixed $handle): array + { + ['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => $page, 'response' => $response] = $handle; $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' => $this->token], - 'query' => [ - 'after' => $after, - 'per_page' => 100, - 'page' => $page, - ], - ]); - $events = $response->toArray(); foreach ($events as $event) { @@ -87,6 +102,17 @@ final class GitLabProvider implements ProviderInterface } $page++; + + if (count($events) === 100) { + $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [ + 'headers' => ['PRIVATE-TOKEN' => $this->token], + 'query' => [ + 'after' => $after, + 'per_page' => 100, + 'page' => $page, + ], + ]); + } } while (count($events) === 100); $this->logger->info('GitLabProvider: fetched contributions', [ -- 2.54.0 From fcf841f7ada1e0b11e5f8f79eeb6ff8e8ec70fd6 Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:36 +0200 Subject: [PATCH 08/42] feat(aggregator): fetch all providers concurrently aggregate() now fires every configured provider's startFetch() in one pass before resolving any of them, so wall-clock cost is roughly max(providers) instead of sum(providers). Partial-failure isolation and per-date summation behavior are unchanged. --- src/Service/ContributionAggregator.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Service/ContributionAggregator.php b/src/Service/ContributionAggregator.php index 24d37cb..8a8b59a 100644 --- a/src/Service/ContributionAggregator.php +++ b/src/Service/ContributionAggregator.php @@ -19,7 +19,7 @@ final class ContributionAggregator /** @return array */ public function aggregate(): array { - $contributions = []; + $pending = []; /** @var ProviderInterface $provider */ foreach ($this->providers as $provider) { @@ -28,7 +28,17 @@ final class ContributionAggregator } try { - foreach ($provider->fetch() as $date => $count) { + $pending[] = [$provider, $provider->startFetch()]; + } catch (\Throwable $e) { + $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]); + } + } + + $contributions = []; + + foreach ($pending as [$provider, $handle]) { + try { + foreach ($provider->resolveFetch($handle) as $date => $count) { $contributions[$date] = ($contributions[$date] ?? 0) + $count; } } catch (\Throwable $e) { -- 2.54.0 From f2bf24994c9bc2340015f14aa43751a106f829e4 Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:44 +0200 Subject: [PATCH 09/42] test(provider): update ProbeTraitTest stub for split fetch API The anonymous ProviderInterface implementation needs startFetch()/ resolveFetch() instead of fetch() to stay loadable. --- tests/Unit/Service/Provider/ProbeTraitTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Service/Provider/ProbeTraitTest.php b/tests/Unit/Service/Provider/ProbeTraitTest.php index e64c706..97ae24b 100644 --- a/tests/Unit/Service/Provider/ProbeTraitTest.php +++ b/tests/Unit/Service/Provider/ProbeTraitTest.php @@ -31,7 +31,8 @@ final class ProbeTraitTest extends TestCase public function isConfigured(): bool { return $this->configured; } public function getName(): string { return 'test'; } public function ping(): void { ($this->ping)(); } - public function fetch(): array { return []; } + public function startFetch(): mixed { return null; } + public function resolveFetch(mixed $handle): array { return []; } }; } -- 2.54.0 From 5c47888a49a3e9550741b1179b7db68a85339ece Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:52 +0200 Subject: [PATCH 10/42] test(github): exercise startFetch()/resolveFetch() instead of fetch() --- tests/Unit/Service/Provider/GitHubProviderTest.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Service/Provider/GitHubProviderTest.php b/tests/Unit/Service/Provider/GitHubProviderTest.php index 1e8b9f4..0307a12 100644 --- a/tests/Unit/Service/Provider/GitHubProviderTest.php +++ b/tests/Unit/Service/Provider/GitHubProviderTest.php @@ -96,7 +96,8 @@ final class GitHubProviderTest extends TestCase ]], ])); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame(4, $result['2024-06-10']); $this->assertSame(2, $result['2024-06-11']); @@ -112,7 +113,8 @@ final class GitHubProviderTest extends TestCase ]], ])); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertArrayNotHasKey('2024-06-11', $result); } @@ -130,7 +132,8 @@ final class GitHubProviderTest extends TestCase $this->expectException(ServiceUnavailableHttpException::class); - $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $provider->resolveFetch($provider->startFetch()); } #[Test] @@ -139,7 +142,8 @@ final class GitHubProviderTest extends TestCase $client = $this->createStub(HttpClientInterface::class); $client->method('request')->willReturn($this->stubGraphqlResponse([])); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame([], $result); } -- 2.54.0 From c89b4e92122f08861753784129532a41c6a079cc Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:31:59 +0200 Subject: [PATCH 11/42] test(gitea): exercise startFetch()/resolveFetch() instead of fetch() --- tests/Unit/Service/Provider/GiteaProviderTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Service/Provider/GiteaProviderTest.php b/tests/Unit/Service/Provider/GiteaProviderTest.php index e5ab91e..27faac3 100644 --- a/tests/Unit/Service/Provider/GiteaProviderTest.php +++ b/tests/Unit/Service/Provider/GiteaProviderTest.php @@ -78,7 +78,8 @@ final class GiteaProviderTest extends TestCase ['timestamp' => $now, 'contributions' => 5], ])); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame(5, $result[date('Y-m-d', $now)]); } @@ -93,7 +94,8 @@ final class GiteaProviderTest extends TestCase ['timestamp' => $old, 'contributions' => 3], ])); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame([], $result); } @@ -104,7 +106,8 @@ final class GiteaProviderTest extends TestCase $client = $this->createStub(HttpClientInterface::class); $client->method('request')->willReturn($this->stubResponse([])); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame([], $result); } -- 2.54.0 From 1e2f022579e6cf517f3b0bea831c734c8035d4bb Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:32:05 +0200 Subject: [PATCH 12/42] test(gitlab): exercise startFetch()/resolveFetch() instead of fetch() --- tests/Unit/Service/Provider/GitLabProviderTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Service/Provider/GitLabProviderTest.php b/tests/Unit/Service/Provider/GitLabProviderTest.php index ef831cb..d52478f 100644 --- a/tests/Unit/Service/Provider/GitLabProviderTest.php +++ b/tests/Unit/Service/Provider/GitLabProviderTest.php @@ -71,7 +71,8 @@ final class GitLabProviderTest extends TestCase $this->expectException(NotFoundHttpException::class); - $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $provider->resolveFetch($provider->startFetch()); } #[Test] @@ -92,7 +93,8 @@ final class GitLabProviderTest extends TestCase } ); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame(2, $result['2024-06-10']); $this->assertSame(1, $result['2024-06-11']); @@ -119,7 +121,8 @@ final class GitLabProviderTest extends TestCase } ); - $result = $this->makeProvider(client: $client)->fetch(); + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch()); $this->assertSame(2, $callCount); $this->assertSame(100, $result['2024-06-10']); -- 2.54.0 From 8fb7781227274ed541b5eb65458a4da6d56eca8a Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:32:11 +0200 Subject: [PATCH 13/42] test(aggregator): cover concurrent fetch failure modes Mocks startFetch()/resolveFetch() instead of fetch(), and splits the old single failure test into separate start-throws and resolve-throws cases since those are now distinct call sites. --- .../Service/ContributionAggregatorTest.php | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/Unit/Service/ContributionAggregatorTest.php b/tests/Unit/Service/ContributionAggregatorTest.php index b280626..61441f7 100644 --- a/tests/Unit/Service/ContributionAggregatorTest.php +++ b/tests/Unit/Service/ContributionAggregatorTest.php @@ -49,7 +49,7 @@ final class ContributionAggregatorTest extends TestCase { $provider = $this->createStub(ProviderInterface::class); $provider->method('isConfigured')->willReturn(true); - $provider->method('fetch')->willReturn(['2024-01-01' => 3]); + $provider->method('resolveFetch')->willReturn(['2024-01-01' => 3]); $aggregator = new ContributionAggregator([$provider], $this->logger); @@ -63,11 +63,11 @@ final class ContributionAggregatorTest extends TestCase { $providerA = $this->createStub(ProviderInterface::class); $providerA->method('isConfigured')->willReturn(true); - $providerA->method('fetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]); + $providerA->method('resolveFetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]); $providerB = $this->createStub(ProviderInterface::class); $providerB->method('isConfigured')->willReturn(true); - $providerB->method('fetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]); + $providerB->method('resolveFetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]); $aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger); @@ -77,15 +77,15 @@ final class ContributionAggregatorTest extends TestCase } #[Test] - public function it_continues_fetching_remaining_providers_when_one_throws(): void + public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void { $failing = $this->createStub(ProviderInterface::class); $failing->method('isConfigured')->willReturn(true); - $failing->method('fetch')->willThrowException(new \RuntimeException('Network error')); + $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error')); $healthy = $this->createStub(ProviderInterface::class); $healthy->method('isConfigured')->willReturn(true); - $healthy->method('fetch')->willReturn(['2024-01-01' => 7]); + $healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]); $aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); @@ -95,14 +95,45 @@ final class ContributionAggregatorTest extends TestCase } #[Test] - public function it_logs_a_warning_when_a_provider_fetch_throws(): void + public function it_continues_fetching_remaining_providers_when_one_throws_on_resolve(): void + { + $failing = $this->createStub(ProviderInterface::class); + $failing->method('isConfigured')->willReturn(true); + $failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error')); + + $healthy = $this->createStub(ProviderInterface::class); + $healthy->method('isConfigured')->willReturn(true); + $healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]); + + $aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); + + $result = $aggregator->aggregate(); + + $this->assertSame(['2024-01-01' => 7], $result); + } + + #[Test] + public function it_logs_a_warning_when_a_provider_start_fetch_throws(): void { $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once())->method('warning'); $provider = $this->createStub(ProviderInterface::class); $provider->method('isConfigured')->willReturn(true); - $provider->method('fetch')->willThrowException(new \RuntimeException('fail')); + $provider->method('startFetch')->willThrowException(new \RuntimeException('fail')); + + (new ContributionAggregator([$provider], $logger))->aggregate(); + } + + #[Test] + public function it_logs_a_warning_when_a_provider_resolve_fetch_throws(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + + $provider = $this->createStub(ProviderInterface::class); + $provider->method('isConfigured')->willReturn(true); + $provider->method('resolveFetch')->willThrowException(new \RuntimeException('fail')); (new ContributionAggregator([$provider], $logger))->aggregate(); } -- 2.54.0 From cd326a34ba1ad1b661004d81fcb153acde441475 Mon Sep 17 00:00:00 2001 From: Haylan Date: Tue, 7 Jul 2026 13:32:17 +0200 Subject: [PATCH 14/42] chore(controller): lower set_time_limit now that fetching is concurrent Providers fetch in parallel now (cost ~= max, not sum), so the old 90s stopgap for the 3-sequential-providers-plus-pagination worst case can come back down toward the 30s default, with headroom for pagination and per-request HTTP timeouts. --- src/Controller/GraphController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index ccb1786..18b520f 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -53,6 +53,7 @@ final class GraphController $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string { $cacheMiss = true; $item->expiresAfter(3600); + set_time_limit(30); // ponytail: providers fetch concurrently now (cost ~= max, not sum); GitLab pagination + per-request HTTP timeout (framework.yaml) still need headroom over the 30s default return $this->renderer->render($this->aggregator->aggregate(), $theme); }); -- 2.54.0 From ca94b80bebc2f23d924569db902412d14159ccf8 Mon Sep 17 00:00:00 2001 From: Haylan Date: Wed, 8 Jul 2026 06:49:18 +0200 Subject: [PATCH 15/42] chore(composer): remove deprecated Guzzle and GraphQL client configurations --- composer.json | 2 - composer.lock | 1809 ++++------------------ config/packages/eight_points_guzzle.yaml | 7 - config/packages/idci_graphql_client.yaml | 4 - 4 files changed, 308 insertions(+), 1514 deletions(-) delete mode 100644 config/packages/eight_points_guzzle.yaml delete mode 100644 config/packages/idci_graphql_client.yaml diff --git a/composer.json b/composer.json index 60fbf9c..eb44f46 100644 --- a/composer.json +++ b/composer.json @@ -5,8 +5,6 @@ "license": "MIT", "require": { "php": ">=8.2", - "eightpoints/guzzle-bundle": "^8.6", - "idci/graphql-client-bundle": "^2.0", "monolog/monolog": "^3.10", "symfony/cache": "7.4.*", "symfony/console": "7.4.*", diff --git a/composer.lock b/composer.lock index e7e2013..b621df9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,521 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be731d761a63c1a5545d2ab7b285e0b2", + "content-hash": "9e1d0aa40107f1c8cda205ed101547fe", "packages": [ - { - "name": "cfpinto/graphql", - "version": "0.1.0", - "source": { - "type": "git", - "url": "https://github.com/cfpinto/graphql.git", - "reference": "c3fe5a6d0a1ed5c367278fe71db9037d55a8fb5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/cfpinto/graphql/zipball/c3fe5a6d0a1ed5c367278fe71db9037d55a8fb5b", - "reference": "c3fe5a6d0a1ed5c367278fe71db9037d55a8fb5b", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": ">=7.2.0" - }, - "require-dev": { - "phpunit/phpunit": "^8", - "symfony/var-dumper": "^5.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Claudio Pinto", - "email": "me@cfpinto.com" - } - ], - "description": "A GraphQL query builder class", - "homepage": "https://github.com/cfpinto/graphql", - "keywords": [ - "graphql", - "query builder" - ], - "support": { - "issues": "https://github.com/cfpinto/graphql/issues", - "source": "https://github.com/cfpinto/graphql/tree/0.1.0" - }, - "time": "2020-11-10T22:31:47+00:00" - }, - { - "name": "eightpoints/guzzle-bundle", - "version": "v8.7.0", - "source": { - "type": "git", - "url": "https://github.com/8p/EightPointsGuzzleBundle.git", - "reference": "41fe7d671e0d0535cf6f3ab02265cb42cfe53979" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/8p/EightPointsGuzzleBundle/zipball/41fe7d671e0d0535cf6f3ab02265cb42cfe53979", - "reference": "41fe7d671e0d0535cf6f3ab02265cb42cfe53979", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "^6.5.8|^7.4.5", - "guzzlehttp/promises": "^1.5.3|^2.0", - "guzzlehttp/psr7": "^1.9.1|^2.5", - "php": ">=7.2", - "psr/log": "~1.0|~2.0|~3.0", - "symfony/expression-language": "~5.0|~6.0|~7.0|~8.0", - "symfony/framework-bundle": "~5.0|~6.0|~7.0|~8.0", - "symfony/stopwatch": "~5.0|~6.0|~7.0|~8.0" - }, - "require-dev": { - "symfony/phpunit-bridge": "~5.0|~6.0|~7.0|~8.0", - "symfony/twig-bundle": "~5.0|~6.0|~7.0|~8.0", - "symfony/var-dumper": "~5.0|~6.0|~7.0|~8.0", - "symfony/yaml": "~5.0|~6.0|~7.0|~8.0" - }, - "suggest": { - "namshi/cuzzle": "Outputs Curl command on profiler's page for debugging purposes" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "EightPoints\\Bundle\\GuzzleBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florian Preusner", - "email": "florian.preusner@8points.de", - "homepage": "https://github.com/florianpreusner" - }, - { - "name": "Community", - "homepage": "https://github.com/8p/GuzzleBundle/contributors" - } - ], - "description": "Integrates Guzzle 6.x, a PHP HTTP Client, into Symfony. Comes with easy and powerful configuration options and optional plugins.", - "homepage": "https://github.com/8p/GuzzleBundle", - "keywords": [ - "Guzzle", - "bundle", - "client", - "curl", - "http client", - "rest", - "symfony", - "web service" - ], - "support": { - "issues": "https://github.com/8p/EightPointsGuzzleBundle/issues", - "source": "https://github.com/8p/EightPointsGuzzleBundle/tree/v8.7.0" - }, - "time": "2026-06-23T09:10:26+00:00" - }, - { - "name": "guzzlehttp/guzzle", - "version": "7.12.1", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.1", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5.1", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.1" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2026-06-18T14:12:49+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.5.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2026-06-02T12:23:43+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.12.1", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "1.1.0", - "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.1" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2026-06-18T09:49:37+00:00" - }, - { - "name": "idci/graphql-client-bundle", - "version": "v2.0.2", - "source": { - "type": "git", - "url": "https://github.com/IDCI-Consulting/IDCIGraphQLClientBundle.git", - "reference": "5688b17c117ffc7be992f803e075c0e4ecc607af" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/IDCI-Consulting/IDCIGraphQLClientBundle/zipball/5688b17c117ffc7be992f803e075c0e4ecc607af", - "reference": "5688b17c117ffc7be992f803e075c0e4ecc607af", - "shasum": "" - }, - "require": { - "cfpinto/graphql": "^0.1.0", - "eightpoints/guzzle-bundle": "^8.0", - "php": "^7.1 || ^8.0", - "symfony/dependency-injection": "^6.0 || ^7.0", - "symfony/framework-bundle": "^6.0 || ^7.0", - "symfony/translation": "^6.0 || ^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "suggest": { - "symfony/cache": "^6.0 || ^7.0" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "IDCI\\Bundle\\GraphQLClientBundle\\": "src/" - }, - "exclude-from-classmap": [ - "/tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Benjamin BOUDIER", - "email": "benjamin.boudier@idci-consulting.fr", - "homepage": "http://www.idci-consulting.fr" - } - ], - "description": "Help you to process graphql query", - "keywords": [ - "client", - "graph", - "graphql" - ], - "support": { - "issues": "https://github.com/IDCI-Consulting/IDCIGraphQLClientBundle/issues", - "source": "https://github.com/IDCI-Consulting/IDCIGraphQLClientBundle/tree/v2.0.2" - }, - "time": "2025-04-18T10:34:10+00:00" - }, { "name": "monolog/monolog", "version": "3.10.0", @@ -774,166 +261,6 @@ }, "time": "2019-01-08T18:20:26+00:00" }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, { "name": "psr/log", "version": "3.0.2", @@ -984,62 +311,18 @@ }, "time": "2024-09-11T13:17:53+00:00" }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, { "name": "symfony/cache", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" + "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", - "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", + "url": "https://api.github.com/repos/symfony/cache/zipball/9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", + "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", "shasum": "" }, "require": { @@ -1110,7 +393,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.13" + "source": "https://github.com/symfony/cache/tree/v7.4.14" }, "funding": [ { @@ -1130,20 +413,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:43:14+00:00" + "time": "2026-06-17T14:44:48+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -1190,7 +473,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -1210,37 +493,38 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/config", - "version": "v8.1.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "429783a0c649696f2058ea5ab5315f082dba6de9" + "reference": "7b665e443381ea7c4db03eb03b4bf79ea2b020eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/429783a0c649696f2058ea5ab5315f082dba6de9", - "reference": "429783a0c649696f2058ea5ab5315f082dba6de9", + "url": "https://api.github.com/repos/symfony/config/zipball/7b665e443381ea7c4db03eb03b4bf79ea2b020eb", + "reference": "7b665e443381ea7c4db03eb03b4bf79ea2b020eb", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.4|^8.0", - "symfony/polyfill-ctype": "^1.8" + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" }, "conflict": { + "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^7.4|^8.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -1268,7 +552,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.1.0" + "source": "https://github.com/symfony/config/tree/v7.4.14" }, "funding": [ { @@ -1288,20 +572,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-09T07:51:57+00:00" }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -1366,7 +650,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -1386,20 +670,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2" + "reference": "2c8c64a33e2e6911579e1ff79a8e06c27d48d402" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f299e20ce983be6c0744952533c6dfeaaa1448e2", - "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/2c8c64a33e2e6911579e1ff79a8e06c27d48d402", + "reference": "2c8c64a33e2e6911579e1ff79a8e06c27d48d402", "shasum": "" }, "require": { @@ -1450,7 +734,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.13" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.14" }, "funding": [ { @@ -1470,20 +754,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T14:07:29+00:00" + "time": "2026-06-24T07:41:05+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -1521,7 +805,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1541,36 +825,37 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v8.1.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" + "reference": "4e1a093b481f323e6e326451f9760c3868430673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5" + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -1602,7 +887,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.0" + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" }, "funding": [ { @@ -1622,29 +907,28 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/security-http": "<7.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -1653,14 +937,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -1688,7 +972,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -1708,20 +992,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -1768,7 +1052,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -1788,97 +1072,29 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" - }, - { - "name": "symfony/expression-language", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/expression-language.git", - "reference": "67f31731d5b316d0183c565933017d5d3331d609" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/67f31731d5b316d0183c565933017d5d3331d609", - "reference": "67f31731d5b316d0183c565933017d5d3331d609", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/cache": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\ExpressionLanguage\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an engine that can compile and evaluate expressions", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/expression-language/tree/v8.1.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v8.1.0", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^7.4|^8.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -1906,7 +1122,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -1926,27 +1142,27 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", - "version": "v8.1.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "58d2e767a66052c1487356f953445634a8194c64" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", - "reference": "58d2e767a66052c1487356f953445634a8194c64", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^7.4|^8.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -1974,7 +1190,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.0" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -1994,20 +1210,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "8be39c7bf9e6f58fe49c07927572a9df7c961c95" + "reference": "4d11fd50d0a3d2c43b400154ad7ec35b84ea3e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/8be39c7bf9e6f58fe49c07927572a9df7c961c95", - "reference": "8be39c7bf9e6f58fe49c07927572a9df7c961c95", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/4d11fd50d0a3d2c43b400154ad7ec35b84ea3e5b", + "reference": "4d11fd50d0a3d2c43b400154ad7ec35b84ea3e5b", "shasum": "" }, "require": { @@ -2057,7 +1273,7 @@ "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", - "symfony/webhook": "<7.2", + "symfony/webhook": "<7.4", "symfony/workflow": "<7.4" }, "require-dev": { @@ -2101,7 +1317,7 @@ "symfony/uid": "^6.4|^7.0|^8.0", "symfony/validator": "^7.4|^8.0", "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/webhook": "^7.2|^8.0", + "symfony/webhook": "^7.4|^8.0", "symfony/workflow": "^7.4|^8.0", "symfony/yaml": "^7.3|^8.0", "twig/twig": "^3.12" @@ -2132,7 +1348,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.13" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.14" }, "funding": [ { @@ -2152,20 +1368,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:04:28+00:00" + "time": "2026-06-27T08:31:38+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -2233,7 +1449,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.13" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -2253,20 +1469,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T09:57:54+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -2315,7 +1531,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -2335,40 +1551,41 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<4.3" + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^4.3", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2396,7 +1613,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" }, "funding": [ { @@ -2416,63 +1633,78 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-11T07:31:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "0f2b114380b21d8736a496ab4672b012e3822ee7" + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0f2b114380b21d8736a496ab4672b012e3822ee7", - "reference": "0f2b114380b21d8736a496ab4672b012e3822ee7", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "twig/twig": "<3.21" + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/dom-crawler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/routing": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/translation": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0", - "symfony/var-exporter": "^7.4|^8.0", - "twig/twig": "^3.21" + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" }, "type": "library", "autoload": { @@ -2500,7 +1732,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.13" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" }, "funding": [ { @@ -2520,36 +1752,42 @@ "type": "tidelift" } ], - "time": "2026-05-27T08:48:59+00:00" + "time": "2026-06-27T09:14:35+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v8.1.0", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "38563fac41ede8521e5e3dc139a4f2b097471c8c" + "reference": "20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/38563fac41ede8521e5e3dc139a4f2b097471c8c", - "reference": "38563fac41ede8521e5e3dc139a4f2b097471c8c", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8", + "reference": "20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8", "shasum": "" }, "require": { "monolog/monolog": "^3", - "php": ">=8.4.1", - "symfony/http-kernel": "^7.4|^8.0", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3" }, + "conflict": { + "symfony/console": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/security-core": "<6.4" + }, "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/mailer": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/security-core": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -2577,7 +1815,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v8.1.0" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.12" }, "funding": [ { @@ -2597,7 +1835,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/monolog-bundle", @@ -2761,93 +1999,6 @@ ], "time": "2026-04-10T16:19:22+00:00" }, - { - "name": "symfony/polyfill-deepclone", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "provide": { - "ext-deepclone": "*" - }, - "suggest": { - "ext-deepclone": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\DeepClone\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the deepclone extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "deepclone", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-26T13:03:27+00:00" - }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.38.1", @@ -3017,16 +2168,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -3078,7 +2229,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -3098,104 +2249,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "8339098cae28673c15cce00d80734af0453054e2" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", - "reference": "8339098cae28673c15cce00d80734af0453054e2", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -3242,7 +2309,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -3262,7 +2329,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", @@ -3426,29 +2493,34 @@ }, { "name": "symfony/routing", - "version": "v8.1.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3482,7 +2554,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.0" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -3502,20 +2574,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "1a24cf8aab3a9378117718b35525c4126ad3adec" + "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/1a24cf8aab3a9378117718b35525c4126ad3adec", - "reference": "1a24cf8aab3a9378117718b35525c4126ad3adec", + "url": "https://api.github.com/repos/symfony/runtime/zipball/15497f743dd714f3167cb6a56509b9d42e6417b3", + "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3", "shasum": "" }, "require": { @@ -3523,7 +2595,8 @@ "php": ">=8.2" }, "conflict": { - "symfony/dotenv": "<6.4" + "symfony/dotenv": "<6.4", + "symfony/http-foundation": "<6.4" }, "require-dev": { "composer/composer": "^2.6", @@ -3566,7 +2639,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.13" + "source": "https://github.com/symfony/runtime/tree/v7.4.14" }, "funding": [ { @@ -3586,20 +2659,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:04:28+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -3653,7 +2726,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -3673,104 +2746,39 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" - }, - { - "name": "symfony/stopwatch", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "21c07b026905d596e8379caeb115d87aa479499d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", - "reference": "21c07b026905d596e8379caeb115d87aa479499d", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/service-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Stopwatch\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a way to profile code", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3809,7 +2817,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -3829,217 +2837,35 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { - "name": "symfony/translation", - "version": "v7.4.10", + "name": "symfony/var-dumper", + "version": "v7.4.14", "source": { "type": "git", - "url": "https://github.com/symfony/translation.git", - "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde" + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde", - "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5.3|^3.3" + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "nikic/php-parser": "<5.0", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<6.4", - "symfony/yaml": "<6.4" - }, - "provide": { - "symfony/translation-implementation": "2.3|3.0" + "symfony/console": "<6.4" }, "require-dev": { - "nikic/php-parser": "^5.0", - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/http-client-contracts": "^2.5|^3.0", "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", - "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "files": [ - "Resources/functions.php" - ], - "psr-4": { - "Symfony\\Component\\Translation\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides tools to internationalize your application", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.10" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-06T11:19:24+00:00" - }, - { - "name": "symfony/translation-contracts", - "version": "v3.7.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Translation\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to translation", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-05T13:30:16+00:00" - }, - { - "name": "symfony/var-dumper", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/var-dumper.git", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/polyfill-mbstring": "^1.0" - }, - "conflict": { - "symfony/console": "<7.4", - "symfony/error-handler": "<7.4" - }, - "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -4078,7 +2904,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -4098,31 +2924,30 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + "reference": "0118811b1d59f323bf131250b3fb919febfece28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0118811b1d59f323bf131250b3fb919febfece28", + "reference": "0118811b1d59f323bf131250b3fb919febfece28", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.37" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4147,12 +2972,11 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "description": "Allows exporting any serializable PHP data structure to plain PHP code", "homepage": "https://symfony.com", "keywords": [ "clone", "construct", - "deep-clone", "export", "hydrate", "instantiate", @@ -4161,7 +2985,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.14" }, "funding": [ { @@ -4181,20 +3005,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-27T08:41:53+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", "shasum": "" }, "require": { @@ -4237,7 +3061,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.13" + "source": "https://github.com/symfony/yaml/tree/v7.4.14" }, "funding": [ { @@ -4257,7 +3081,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T06:06:12+00:00" + "time": "2026-06-08T20:24:16+00:00" } ], "packages-dev": [ @@ -4323,20 +3147,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -4375,9 +3198,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -4846,24 +3669,24 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -4928,31 +3751,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "sebastian/cli-parser", @@ -5994,16 +4801,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d" + "reference": "11eeee9d109963145e66f5b1919e5cf5411da58b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/140bbbe1cd1c21a084494ccddeee33f3c3381d7d", - "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/11eeee9d109963145e66f5b1919e5cf5411da58b", + "reference": "11eeee9d109963145e66f5b1919e5cf5411da58b", "shasum": "" }, "require": { @@ -6055,7 +4862,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.14" }, "funding": [ { @@ -6075,7 +4882,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/packages/eight_points_guzzle.yaml b/config/packages/eight_points_guzzle.yaml deleted file mode 100644 index adabd01..0000000 --- a/config/packages/eight_points_guzzle.yaml +++ /dev/null @@ -1,7 +0,0 @@ -eight_points_guzzle: - clients: - github_graphql: - base_url: 'https://api.github.com/graphql' - options: - headers: - Authorization: 'Bearer %env(GITHUB_TOKEN)%' diff --git a/config/packages/idci_graphql_client.yaml b/config/packages/idci_graphql_client.yaml deleted file mode 100644 index 32b70ac..0000000 --- a/config/packages/idci_graphql_client.yaml +++ /dev/null @@ -1,4 +0,0 @@ -idci_graphql_client: - clients: - github: - http_client: 'eight_points_guzzle.client.github_graphql' -- 2.54.0 From f52a3449c63a8b762a12f25d2f5e8e91d8a50ab9 Mon Sep 17 00:00:00 2001 From: Haylan Date: Wed, 8 Jul 2026 06:49:28 +0200 Subject: [PATCH 16/42] chore(TODO): mark completed tasks and update contribution store details --- TODO.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 0da9570..d55a09f 100644 --- a/TODO.md +++ b/TODO.md @@ -6,17 +6,19 @@ Order matters — later steps assume earlier ones are done. ## 1. Audit cleanup -- [ ] **Remove `eightpoints/guzzle-bundle` + `idci/graphql-client-bundle`.** +- [x] **Remove `eightpoints/guzzle-bundle` + `idci/graphql-client-bundle`.** They exist only to build one near-static GitHub GraphQL query string (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already - bypassed (comment at `GitHubProvider.php:76-77`). Replace + bypassed (comment at `GitHubProvider.php:76-77`). + - [ ] Replace `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain `sprintf`/heredoc query string. Delete the two packages from `composer.json`, `config/bundles.php`, and delete `config/packages/eight_points_guzzle.yaml` + `config/packages/idci_graphql_client.yaml`. Run `composer update` and commit the regenerated `composer.lock`. -- [ ] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, + +- [x] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, 53) and `GiteaProvider.php` (lines 42, 54) each call `rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in `fetch()`. Compute it once as a `private readonly string $baseUrl` in @@ -35,16 +37,16 @@ Order matters — later steps assume earlier ones are done. ## 3. `ContributionStore` (SQLite via native PDO) -- [ ] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at +- [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at `%kernel.project_dir%/var/data/contributions.db` (configurable constructor arg), table created lazily: ```sql CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, date TEXT NOT NULL, - count INTEGER NOT NULL, + count INTEGER NOT NULL CHECK (count >= 0), PRIMARY KEY (provider, date) - ); + ) WITHOUT ROWID, STRICT; ``` - [ ] `latestDate(string $provider): ?string` — `SELECT MAX(date) WHERE provider = ?`. - [ ] `merge(string $provider, array $dateCounts): void` — upsert via -- 2.54.0 From 5e27a5bc934238596fe4c55703157dfe748a70d4 Mon Sep 17 00:00:00 2001 From: Haylan Date: Wed, 8 Jul 2026 06:50:25 +0200 Subject: [PATCH 17/42] fix(provider): dedoupet base URL is properly formatted in GitLab and Gitea providers --- src/Service/Provider/GitLabProvider.php | 22 +++++++++++----------- src/Service/Provider/GiteaProvider.php | 14 ++++++++------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/Service/Provider/GitLabProvider.php b/src/Service/Provider/GitLabProvider.php index 3ddf9fd..4dc1ade 100644 --- a/src/Service/Provider/GitLabProvider.php +++ b/src/Service/Provider/GitLabProvider.php @@ -25,7 +25,9 @@ final class GitLabProvider implements ProviderInterface private readonly string $token, private readonly LoggerInterface $logger, private readonly string $baseUrl = '', - ) {} + ) { + $this->baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); + } public function getName(): string { @@ -39,9 +41,8 @@ final class GitLabProvider implements ProviderInterface public function ping(): void { - $baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); - - $this->client->request('GET', "$baseUrl/api/v4/user", [ + + $this->client->request('GET', "$this->baseUrl/api/v4/user", [ 'headers' => ['PRIVATE-TOKEN' => $this->token], ])->getContent(); } @@ -56,23 +57,22 @@ final class GitLabProvider implements ProviderInterface */ public function startFetch(): array { - $baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); + + $this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]); - $this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]); - - $userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [ + $userResponse = $this->client->request('GET', "$this->baseUrl/api/v4/users", [ 'headers' => ['PRIVATE-TOKEN' => $this->token], 'query' => ['username' => $this->username], ]); $users = $userResponse->toArray(); if (empty($users)) { - throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $baseUrl"); + throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $this->baseUrl"); } $userId = $users[0]['id']; $after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d'); - $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [ + $response = $this->client->request('GET', "$this->baseUrl/api/v4/users/$userId/events", [ 'headers' => ['PRIVATE-TOKEN' => $this->token], 'query' => [ 'after' => $after, @@ -81,7 +81,7 @@ final class GitLabProvider implements ProviderInterface ], ]); - return ['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response]; + return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response]; } /** diff --git a/src/Service/Provider/GiteaProvider.php b/src/Service/Provider/GiteaProvider.php index 050b319..5183c25 100644 --- a/src/Service/Provider/GiteaProvider.php +++ b/src/Service/Provider/GiteaProvider.php @@ -26,7 +26,9 @@ final class GiteaProvider implements ProviderInterface private readonly string $token, private readonly string $baseUrl, private readonly LoggerInterface $logger, - ) {} + ) { + $this->baseUrl = rtrim($this->baseUrl, '/'); + } public function getName(): string { @@ -40,20 +42,20 @@ final class GiteaProvider implements ProviderInterface public function ping(): void { - $baseUrl = rtrim($this->baseUrl, '/'); + - $this->client->request('GET', "$baseUrl/api/v1/user", [ + $this->client->request('GET', "$this->baseUrl/api/v1/user", [ 'headers' => ['Authorization' => "token {$this->token}"], ])->getContent(); } public function startFetch(): ResponseInterface { - $baseUrl = rtrim($this->baseUrl, '/'); + - $this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]); + $this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]); - return $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [ + return $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [ 'headers' => ['Authorization' => "token {$this->token}"], ]); } -- 2.54.0 From cb89a1c6b9fb58cd44c932ecf7756091058e70fd Mon Sep 17 00:00:00 2001 From: Haylan Date: Wed, 8 Jul 2026 06:50:39 +0200 Subject: [PATCH 18/42] chore(docker): update volume mappings for development environment --- docker-compose.override.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 78852c5..facf4d6 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -6,7 +6,8 @@ services: dockerfile: Dockerfile.dev volumes: - .:/app - - /app/vendor # keeps vendor from the dev image, not your local dir + - ./vendor:/app/vendor + - /app/var/data environment: APP_ENV: dev APP_DEBUG: "1" -- 2.54.0 From 3d2c0487b790dd74b82ea0b31e3e3f0460aed390 Mon Sep 17 00:00:00 2001 From: Haylan Date: Wed, 8 Jul 2026 06:50:56 +0200 Subject: [PATCH 19/42] feat(store): fist stepts for implement ContributionStore for managing contributions in SQLite --- config/reference.php | 3 ++- src/Service/ContributionStore.php | 35 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/Service/ContributionStore.php diff --git a/config/reference.php b/config/reference.php index ea15291..cd63856 100644 --- a/config/reference.php +++ b/config/reference.php @@ -121,7 +121,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array @@ -857,6 +857,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * http_errors?: bool|Param, * expect?: mixed, * ssl_key?: mixed, + * force_ip_resolve?: mixed, // Default: null * stream?: bool|Param, * synchronous?: bool|Param, * read_timeout?: scalar|Param|null, diff --git a/src/Service/ContributionStore.php b/src/Service/ContributionStore.php new file mode 100644 index 0000000..7d61f90 --- /dev/null +++ b/src/Service/ContributionStore.php @@ -0,0 +1,35 @@ +dbPath))){ + mkdir(\dirname($this->dbPath), 0755, recursive: true); + } + $this->pdo = new PDO('sqlite:' . $this->dbPath); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION); + $this->prepareSchema(); + } + + private function prepareSchema():void{ + $this->pdo->exec(' + CREATE TABLE IF NOT EXISTS contributions ( + provider TEXT NOT NULL, + date TEXT NOT NULL, + count INTEGER NOT NULL CHECK (count >= 0), + PRIMARY KEY (provider, date) + ) WITHOUT ROWID, STRICT + '); + } + +} -- 2.54.0 From c929fdfda61fd9413afb69179038e18b361d9ab0 Mon Sep 17 00:00:00 2001 From: Haylan Date: Wed, 8 Jul 2026 20:20:42 +0200 Subject: [PATCH 20/42] feat(db): prepared add and remove function --- src/Service/ContributionStore.php | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Service/ContributionStore.php b/src/Service/ContributionStore.php index 7d61f90..2c9f1ed 100644 --- a/src/Service/ContributionStore.php +++ b/src/Service/ContributionStore.php @@ -25,11 +25,35 @@ class ContributionStore { $this->pdo->exec(' CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, - date TEXT NOT NULL, + date INTEGER NOT NULL, count INTEGER NOT NULL CHECK (count >= 0), PRIMARY KEY (provider, date) ) WITHOUT ROWID, STRICT '); } + + public function add(string $provider, int $unixtime, int $count):void{ + $stmt = $this->pdo->prepare(' + INSERT INTO contributions ( + provider, date, count + ) + VALUES( + ?,?,? + ) + '); + $stmt->execute([$provider,$unixtime,$count]); + } + + public function remove(string $provider, int $unixtime):void{ + $stmt = $this->pdo->prepare(' + DELETE contributions + WHERE provider LIKE ? and date = ? + '); + $stmt->execute([$provider,$unixtime]); + } + + public function all():void{ + + } } -- 2.54.0 From 78e7a72ee989b853fe6c3d797dff7b7d9f2dfbf4 Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:04 +0200 Subject: [PATCH 21/42] fix(provider): normalize baseUrl before assigning readonly property Promoted readonly properties can't be reassigned in the constructor body, so GitLabProvider and GiteaProvider threw on every instantiation. Normalize the plain parameter first, then assign to a separately declared readonly property. --- src/Service/Provider/GitLabProvider.php | 6 ++++-- src/Service/Provider/GiteaProvider.php | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Service/Provider/GitLabProvider.php b/src/Service/Provider/GitLabProvider.php index 4dc1ade..26041dd 100644 --- a/src/Service/Provider/GitLabProvider.php +++ b/src/Service/Provider/GitLabProvider.php @@ -19,14 +19,16 @@ final class GitLabProvider implements ProviderInterface { use ProbeTrait; + private readonly string $baseUrl; + public function __construct( private readonly HttpClientInterface $client, private readonly string $username, private readonly string $token, private readonly LoggerInterface $logger, - private readonly string $baseUrl = '', + string $baseUrl = '', ) { - $this->baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); + $this->baseUrl = rtrim($baseUrl !== '' ? $baseUrl : 'https://gitlab.com', '/'); } public function getName(): string diff --git a/src/Service/Provider/GiteaProvider.php b/src/Service/Provider/GiteaProvider.php index 5183c25..3448c03 100644 --- a/src/Service/Provider/GiteaProvider.php +++ b/src/Service/Provider/GiteaProvider.php @@ -20,14 +20,16 @@ final class GiteaProvider implements ProviderInterface { use ProbeTrait; + private readonly string $baseUrl; + public function __construct( private readonly HttpClientInterface $client, private readonly string $username, private readonly string $token, - private readonly string $baseUrl, + string $baseUrl, private readonly LoggerInterface $logger, ) { - $this->baseUrl = rtrim($this->baseUrl, '/'); + $this->baseUrl = rtrim($baseUrl, '/'); } public function getName(): string -- 2.54.0 From 885dec357eeeeb24c28f752f41cb52fa11e60f41 Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:11 +0200 Subject: [PATCH 22/42] refactor(github): build GraphQL query without the client bundle The eightpoints/guzzle-bundle and idci/graphql-client-bundle only existed to build one near-static query string, and the bundle's own HTTP transport was already bypassed in favor of Symfony HttpClient. Build the query with sprintf/json_encode instead, drop both bundles from config/bundles.php, and update the test double accordingly. --- config/bundles.php | 2 - config/reference.php | 61 +------------------ src/Service/Provider/GitHubProvider.php | 28 +++------ .../Service/Provider/GitHubProviderTest.php | 16 ----- 4 files changed, 9 insertions(+), 98 deletions(-) diff --git a/config/bundles.php b/config/bundles.php index 80187e8..5b11b41 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -3,6 +3,4 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], - EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true], - IDCI\Bundle\GraphQLClientBundle\IDCIGraphQLClientBundle::class => ['all' => true], ]; diff --git a/config/reference.php b/config/reference.php index cd63856..3a03fa6 100644 --- a/config/reference.php +++ b/config/reference.php @@ -37,7 +37,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * @psalm-type ArgumentsType = list|array * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key - * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator * @psalm-type DeprecationType = array{package: string, version: string, message?: string} * @psalm-type DefaultsType = array{ * public?: bool, @@ -302,7 +302,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool|Param, // Default: true + * enabled?: bool|Param, // Default: false * fallbacks?: string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" @@ -833,73 +833,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * }>, * } - * @psalm-type EightPointsGuzzleConfig = array{ - * clients?: array, - * allow_redirects?: mixed, - * auth?: mixed, - * query?: mixed, - * curl?: list, - * cert?: mixed, - * connect_timeout?: scalar|Param|null, - * debug?: bool|Param, - * decode_content?: mixed, - * delay?: float|Param, - * form_params?: array, - * multipart?: list, - * sink?: scalar|Param|null, - * http_errors?: bool|Param, - * expect?: mixed, - * ssl_key?: mixed, - * force_ip_resolve?: mixed, // Default: null - * stream?: bool|Param, - * synchronous?: bool|Param, - * read_timeout?: scalar|Param|null, - * timeout?: scalar|Param|null, - * verify?: mixed, - * cookies?: bool|Param, - * proxy?: string|array{ - * http?: scalar|Param|null, - * https?: scalar|Param|null, - * no?: list, - * }, - * version?: scalar|Param|null, - * }, - * plugin?: array, - * }>, - * logging?: bool|Param, // Default: true - * profiling?: bool|Param, // Default: true - * slow_response_time?: int|Param, // Default: 0 - * } - * @psalm-type IdciGraphqlClientConfig = array{ - * cache_enabled?: bool|Param, // Default: false - * clients?: list, - * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, * monolog?: MonologConfig, - * eight_points_guzzle?: EightPointsGuzzleConfig, - * idci_graphql_client?: IdciGraphqlClientConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, * monolog?: MonologConfig, - * eight_points_guzzle?: EightPointsGuzzleConfig, - * idci_graphql_client?: IdciGraphqlClientConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -907,8 +852,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * services?: ServicesConfig, * framework?: FrameworkConfig, * monolog?: MonologConfig, - * eight_points_guzzle?: EightPointsGuzzleConfig, - * idci_graphql_client?: IdciGraphqlClientConfig, * }, * ...format('Y-m-d\T00:00:00\Z'); $to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); - /** @var GraphQLApiClient $graphqlClient */ - $graphqlClient = $this->registry->get('github'); + $query = sprintf( + 'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }', + json_encode($this->username), + json_encode($from), + json_encode($to), + ); - $query = $graphqlClient->buildQuery( - ['user' => ['login' => $this->username]], - [ - 'contributionsCollection' => [ - '_parameters' => ['from' => $from, 'to' => $to], - 'contributionCalendar' => [ - 'weeks' => [ - 'contributionDays' => ['date', 'contributionCount'], - ], - ], - ], - ] - )->getGraphQLQuery(); - - // GitHub's GraphQL API requires application/json — the bundle's built-in - // transport sends form_params, so we use Symfony HttpClient here instead. + // GitHub's GraphQL API requires application/json. // request() doesn't block; the response is read in resolveFetch() so // multiple providers' requests can be in flight at once. return $this->client->request('POST', self::GRAPHQL_URL, [ diff --git a/tests/Unit/Service/Provider/GitHubProviderTest.php b/tests/Unit/Service/Provider/GitHubProviderTest.php index 0307a12..fbb1907 100644 --- a/tests/Unit/Service/Provider/GitHubProviderTest.php +++ b/tests/Unit/Service/Provider/GitHubProviderTest.php @@ -5,9 +5,6 @@ declare(strict_types=1); namespace App\Tests\Unit\Service\Provider; use App\Service\Provider\GitHubProvider; -use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient; -use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface; -use IDCI\Bundle\GraphQLClientBundle\Query\GraphQLQuery; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -23,22 +20,9 @@ final class GitHubProviderTest extends TestCase string $username = 'user', string $token = 'token', ?HttpClientInterface $client = null, - ?GraphQLApiClientRegistryInterface $registry = null, ): GitHubProvider { - if ($registry === null) { - $graphqlQuery = $this->createStub(GraphQLQuery::class); - $graphqlQuery->method('getGraphQLQuery')->willReturn('query {}'); - - $graphqlClient = $this->createStub(GraphQLApiClient::class); - $graphqlClient->method('buildQuery')->willReturn($graphqlQuery); - - $registry = $this->createStub(GraphQLApiClientRegistryInterface::class); - $registry->method('get')->willReturn($graphqlClient); - } - return new GitHubProvider( $client ?? $this->createStub(HttpClientInterface::class), - $registry, $username, $token, $this->createStub(LoggerInterface::class), -- 2.54.0 From 9087f9185513f7ac457e2767cc0081e41db87b00 Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:21 +0200 Subject: [PATCH 23/42] feat(store): finish ContributionStore and add typed entities - Fix add()'s upsert SQL (was invalid SQL from typos) and use it for the new merge() as well. - Fix remove()'s missing FROM keyword and drop the unindexed LIKE scan in favor of an exact match. - Add latestDate(), all() with optional sinceDays filtering, and prune() with a retention-day cutoff. - Add Contribution (immutable value object) and ContributionCollection (IteratorAggregate + Countable) so all() returns something typed instead of a raw date => count array. - Cover all of the above with unit tests against an in-memory SQLite database. --- src/Entity/Contribution.php | 15 +++ src/Entity/ContributionCollection.php | 29 +++++ src/Service/ContributionStore.php | 96 +++++++++++---- .../Entity/ContributionCollectionTest.php | 43 +++++++ tests/Unit/Entity/ContributionTest.php | 22 ++++ tests/Unit/Service/ContributionStoreTest.php | 113 ++++++++++++++++++ 6 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 src/Entity/Contribution.php create mode 100644 src/Entity/ContributionCollection.php create mode 100644 tests/Unit/Entity/ContributionCollectionTest.php create mode 100644 tests/Unit/Entity/ContributionTest.php create mode 100644 tests/Unit/Service/ContributionStoreTest.php diff --git a/src/Entity/Contribution.php b/src/Entity/Contribution.php new file mode 100644 index 0000000..9e04820 --- /dev/null +++ b/src/Entity/Contribution.php @@ -0,0 +1,15 @@ + + */ +final class ContributionCollection implements \IteratorAggregate, \Countable +{ + /** @var array */ + private readonly array $contributions; + + public function __construct(Contribution ...$contributions) + { + $this->contributions = $contributions; + } + + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->contributions); + } + + public function count(): int + { + return count($this->contributions); + } +} diff --git a/src/Service/ContributionStore.php b/src/Service/ContributionStore.php index 2c9f1ed..c12a32f 100644 --- a/src/Service/ContributionStore.php +++ b/src/Service/ContributionStore.php @@ -4,24 +4,30 @@ declare(strict_types=1); namespace App\Service; +use App\Entity\Contribution; +use App\Entity\ContributionCollection; use PDO; -class ContributionStore { +class ContributionStore +{ private PDO $pdo; + private const int DAY_IN_SECONDS = 86400; public function __construct( private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db", private readonly ?int $retentionDays = null, - ){ - if(!is_dir(\dirname($this->dbPath))){ - mkdir(\dirname($this->dbPath), 0755, recursive: true); + ) { + $dir = \dirname($this->dbPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, recursive: true); } $this->pdo = new PDO('sqlite:' . $this->dbPath); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->prepareSchema(); } - private function prepareSchema():void{ + private function prepareSchema(): void + { $this->pdo->exec(' CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, @@ -32,28 +38,74 @@ class ContributionStore { '); } - public function add(string $provider, int $unixtime, int $count):void{ - $stmt = $this->pdo->prepare(' - INSERT INTO contributions ( - provider, date, count - ) - VALUES( - ?,?,? - ) + public function add(string $provider, int $unixtime, int $count): void + { + $stmt = $this->pdo->prepare(' + INSERT INTO contributions (provider, date, count) + VALUES (?, ?, ?) + ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count '); - $stmt->execute([$provider,$unixtime,$count]); + $stmt->execute([$provider, $unixtime, $count]); } - public function remove(string $provider, int $unixtime):void{ - $stmt = $this->pdo->prepare(' - DELETE contributions - WHERE provider LIKE ? and date = ? + public function remove(string $provider, int $unixtime): void + { + $stmt = $this->pdo->prepare(' + DELETE FROM contributions + WHERE provider = ? AND date = ? '); - $stmt->execute([$provider,$unixtime]); + $stmt->execute([$provider, $unixtime]); } - public function all():void{ + /** + * @param array $dateCounts unix timestamp => count + */ + public function merge(string $provider, array $dateCounts): void + { + foreach ($dateCounts as $unixtime => $count) { + $this->add($provider, $unixtime, $count); + } + } + public function latestDate(string $provider): ?int + { + $stmt = $this->pdo->prepare('SELECT MAX(date) FROM contributions WHERE provider = ?'); + $stmt->execute([$provider]); + $result = $stmt->fetchColumn(); + + return $result !== null ? (int) $result : null; + } + + public function all(string $provider, ?int $sinceDays = null): ContributionCollection + { + $sql = 'SELECT provider, date, count FROM contributions WHERE provider = ?'; + $params = [$provider]; + + if ($sinceDays !== null) { + $sql .= ' AND date >= ?'; + $params[] = time() - $sinceDays * self::DAY_IN_SECONDS; + } + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + $contributions = array_map( + static fn (array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']), + $stmt->fetchAll(PDO::FETCH_ASSOC), + ); + + return new ContributionCollection(...$contributions); + } + + public function prune(): void + { + if ($this->retentionDays === null || $this->retentionDays === 0) { + return; + } + + $cutoff = time() - $this->retentionDays * self::DAY_IN_SECONDS; + + $stmt = $this->pdo->prepare('DELETE FROM contributions WHERE date < ?'); + $stmt->execute([$cutoff]); } - } diff --git a/tests/Unit/Entity/ContributionCollectionTest.php b/tests/Unit/Entity/ContributionCollectionTest.php new file mode 100644 index 0000000..d6b4325 --- /dev/null +++ b/tests/Unit/Entity/ContributionCollectionTest.php @@ -0,0 +1,43 @@ +assertCount(0, $collection); + } + + #[Test] + public function it_counts_the_contributions_it_wraps(): void + { + $collection = new ContributionCollection( + new Contribution('github', 1_700_000_000, 1), + new Contribution('github', 1_700_086_400, 2), + ); + + $this->assertCount(2, $collection); + } + + #[Test] + public function it_is_iterable_over_its_contributions(): void + { + $first = new Contribution('github', 1_700_000_000, 1); + $second = new Contribution('gitlab', 1_700_086_400, 2); + + $collection = new ContributionCollection($first, $second); + + $this->assertSame([$first, $second], iterator_to_array($collection)); + } +} diff --git a/tests/Unit/Entity/ContributionTest.php b/tests/Unit/Entity/ContributionTest.php new file mode 100644 index 0000000..e4f0899 --- /dev/null +++ b/tests/Unit/Entity/ContributionTest.php @@ -0,0 +1,22 @@ +assertSame('github', $contribution->provider); + $this->assertSame(1_700_000_000, $contribution->date); + $this->assertSame(4, $contribution->count); + } +} diff --git a/tests/Unit/Service/ContributionStoreTest.php b/tests/Unit/Service/ContributionStoreTest.php new file mode 100644 index 0000000..4b8af0e --- /dev/null +++ b/tests/Unit/Service/ContributionStoreTest.php @@ -0,0 +1,113 @@ +add('github', 1_700_000_000, 4); + $all = $store->all('github'); + + $this->assertCount(1, $all); + $this->assertSame(4, iterator_to_array($all)[0]->count); + } + + #[Test] + public function it_upserts_on_a_repeated_date(): void + { + $store = new ContributionStore(':memory:'); + + $store->add('github', 1_700_000_000, 4); + $store->add('github', 1_700_000_000, 9); + $all = $store->all('github'); + + $this->assertCount(1, $all); + $this->assertSame(9, iterator_to_array($all)[0]->count); + } + + #[Test] + public function it_removes_a_contribution(): void + { + $store = new ContributionStore(':memory:'); + + $store->add('github', 1_700_000_000, 4); + $store->remove('github', 1_700_000_000); + + $this->assertCount(0, $store->all('github')); + } + + #[Test] + public function it_merges_a_batch_of_date_counts(): void + { + $store = new ContributionStore(':memory:'); + + $store->merge('github', [1_700_000_000 => 1, 1_700_086_400 => 2]); + + $this->assertCount(2, $store->all('github')); + } + + #[Test] + public function it_reports_the_latest_date_for_a_provider(): void + { + $store = new ContributionStore(':memory:'); + + $store->add('github', 1_700_000_000, 1); + $store->add('github', 1_700_086_400, 2); + + $this->assertSame(1_700_086_400, $store->latestDate('github')); + } + + #[Test] + public function it_returns_null_latest_date_when_provider_has_no_rows(): void + { + $store = new ContributionStore(':memory:'); + + $this->assertNull($store->latestDate('github')); + } + + #[Test] + public function it_filters_all_by_since_days(): void + { + $store = new ContributionStore(':memory:'); + $now = time(); + + $store->add('github', $now - 10 * 86400, 1); + $store->add('github', $now - 400 * 86400, 2); + + $this->assertCount(1, $store->all('github', sinceDays: 30)); + } + + #[Test] + public function it_does_not_prune_when_retention_is_unset(): void + { + $store = new ContributionStore(':memory:', retentionDays: null); + + $store->add('github', time() - 1_000 * 86400, 1); + $store->prune(); + + $this->assertCount(1, $store->all('github')); + } + + #[Test] + public function it_prunes_rows_older_than_the_retention_window(): void + { + $store = new ContributionStore(':memory:', retentionDays: 30); + $now = time(); + + $store->add('github', $now - 10 * 86400, 1); + $store->add('github', $now - 40 * 86400, 2); + $store->prune(); + + $this->assertCount(1, $store->all('github')); + } +} -- 2.54.0 From b3dc7a229831d3fa857e3d18ec83153a172685c0 Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:29 +0200 Subject: [PATCH 24/42] fix(docker): target the dev stage when building for local development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.override.yml only overrode the dockerfile, so it kept inheriting target: final from docker-compose.yml — but Dockerfile.dev is single-stage and has no final stage, so the dev build failed. Name the stage in Dockerfile.dev and target it explicitly from the override. --- Dockerfile.dev | 2 +- docker-compose.override.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 8d17d1a..3fa0617 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM dunglas/frankenphp:1-php8.4-alpine +FROM dunglas/frankenphp:1-php8.4-alpine AS dev RUN apk add --no-cache icu-dev libzip-dev \ && docker-php-ext-install -j$(nproc) intl opcache zip \ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index facf4d6..22f5f0b 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -4,6 +4,7 @@ services: graph: build: dockerfile: Dockerfile.dev + target: dev volumes: - .:/app - ./vendor:/app/vendor -- 2.54.0 From bce564d68bed1869333c4c316a405fa908103baa Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:47 +0200 Subject: [PATCH 25/42] docs(readme): fix phpunit command paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no bin/phpunit (only bin/console) — the binary lives at vendor/bin/phpunit, matching CLAUDE.md's documented commands. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index abc108d..af3053f 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ docker compose up -d --build docker compose exec graph sh # Run tests inside the container -docker compose exec graph php bin/phpunit +docker compose exec graph vendor/bin/phpunit # Disable Xdebug for faster test runs XDEBUG_MODE=off docker compose up -d @@ -195,16 +195,16 @@ docker compose -f docker-compose.yml up -d --build ```bash # Run full suite -php bin/phpunit +vendor/bin/phpunit # Human-readable output -php bin/phpunit --testdox +vendor/bin/phpunit --testdox # Single file -php bin/phpunit tests/Unit/Service/SvgRendererTest.php +vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php # Filter by name -php bin/phpunit --filter it_renders +vendor/bin/phpunit --filter it_renders ``` --- -- 2.54.0 From a76ed6efc8213c2e47353017208ea370e7f1cc4c Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:50 +0200 Subject: [PATCH 26/42] chore(todo): mark completed tasks and expand step 3 detail --- TODO.md | 80 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index d55a09f..8ce8925 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,7 @@ Order matters — later steps assume earlier ones are done. They exist only to build one near-static GitHub GraphQL query string (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already bypassed (comment at `GitHubProvider.php:76-77`). - - [ ] Replace + - [x] Replace `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain `sprintf`/heredoc query string. Delete the two packages from `composer.json`, `config/bundles.php`, and delete @@ -26,7 +26,7 @@ Order matters — later steps assume earlier ones are done. ## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4) -- [ ] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the +- [x] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and `_instanceof: App\Service\ProviderInterface`, left over from the `Service/` → `Provider/`+`Renderer/` namespace reorg. Update all of @@ -37,31 +37,78 @@ Order matters — later steps assume earlier ones are done. ## 3. `ContributionStore` (SQLite via native PDO) +> Docs: [PDO](https://www.php.net/manual/en/book.pdo.php) · +> [PDO_SQLITE driver](https://www.php.net/manual/en/ref.pdo-sqlite.php) · +> [PDO::prepare / prepared statements](https://www.php.net/manual/en/pdo.prepare.php) · +> [SQLite `INSERT ... ON CONFLICT` upsert](https://www.sqlite.org/lang_upsert.html) · +> [SQLite `STRICT` tables](https://www.sqlite.org/stricttables.html) · +> [SQLite `WITHOUT ROWID` tables](https://www.sqlite.org/withoutrowid.html) · +> [SQLite datatypes (why `date` is `INTEGER`/unixtime, not `TEXT`)](https://www.sqlite.org/datatype3.html) + - [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at `%kernel.project_dir%/var/data/contributions.db` (configurable - constructor arg), table created lazily: + constructor arg), table created lazily. **Schema note:** `date` is + stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the + current implementation: ```sql CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, - date TEXT NOT NULL, + date INTEGER NOT NULL, count INTEGER NOT NULL CHECK (count >= 0), PRIMARY KEY (provider, date) ) WITHOUT ROWID, STRICT; ``` -- [ ] `latestDate(string $provider): ?string` — `SELECT MAX(date) WHERE provider = ?`. -- [ ] `merge(string $provider, array $dateCounts): void` — upsert via - `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`. -- [ ] `all(string $provider, ?int $sinceDays = null): array` — `date => count`; - `null` returns full history (no arbitrary cutoff), a value filters to the - last N days. -- [ ] `prune(): void` — `DELETE FROM contributions WHERE date < ?` using - the configured retention window; no-ops if retention is unset/0. -- [ ] Constructor takes `?int $retentionDays` bound from new env var - `CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). -- [ ] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store & +- [x] `add(string $provider, int $unixtime, int $count): void` — plain + insert (done, needs test coverage — see below). +- [x] Fix `add()`: currently a plain `INSERT`, so re-adding an existing + `(provider, date)` throws a unique-constraint violation instead of + upserting. Switch to + `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count` + (see SQLite upsert doc above), or keep `add()` insert-only and add a + separate `merge()` for the upsert case used by step 5. +- [x] `remove(string $provider, int $unixtime): void` — started, has a + bug: `DELETE contributions WHERE ...` is invalid SQL, missing the + `FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also + drop the `LIKE` on `provider` (exact match, use `=`) since it's an + unindexed wildcard scan for what should be an exact key lookup. +- [x] **`Contribution` entity.** Small immutable value object + (`src/Entity/Contribution.php`) wrapping one row: `provider` (string), + `date` (unix timestamp int, or `\DateTimeImmutable` — pick one and + use it consistently everywhere, including `add()`/`all()`), `count` + (int). Gives `ContributionStore::all()` and the aggregator something + typed to pass around instead of raw arrays/tuples. +- [x] **`ContributionCollection`.** Typed collection + (`src/Entity/ContributionCollection.php`) wrapping + `array` — implement `IteratorAggregate` + `Countable` + at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php), + [`Countable`](https://www.php.net/manual/en/class.countable.php)) so + it can be `foreach`'d and `count()`'d like a normal array. This is + what `ContributionStore::all()` should return instead of a bare + `date => count` array. +- [x] `latestDate(string $provider): ?int` — `SELECT MAX(date) WHERE provider = ?` + (returns a unix timestamp, not a string, per the schema above). +- [x] `merge(string $provider, array $dateCounts): void` — upsert via + `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`; + `$dateCounts` keyed by unix timestamp. +- [x] `all(string $provider, ?int $sinceDays = null): ContributionCollection` — + fetch all rows for a provider, or since a given range. `null` returns + full history (no arbitrary cutoff), a value filters to rows where + `date >= (now - sinceDays * 86400)`. **Bug:** current stub in + `ContributionStore.php:58` has invalid PHP (`$provider == null` instead + of `?string $provider = null` — this won't parse) and returns `void` + instead of `ContributionCollection`; fix the signature when implementing. +- [x] `prune(): void` — `DELETE FROM contributions WHERE date < ?` using + the configured retention window (a unix timestamp cutoff); no-ops if + retention is unset/0. +- [x] Constructor takes `?int $retentionDays` bound from new env var + `CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). *(constructor + param wired; the env binding itself is step 6's job.)* +- [x] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store & retrieve, upsert overwrites existing date, `sinceDays` filtering, `prune()` no-ops when retention unset, `prune()` deletes rows older than the window when set. +- [x] Tests: `tests/Unit/Entity/ContributionTest.php` and + `ContributionCollectionTest.php` — construction, iteration, `count()`. ## 4. Wire `$since` through providers @@ -84,7 +131,8 @@ Order matters — later steps assume earlier ones are done. overlap for late corrections), else `null` (first run). - [ ] `$fresh = $provider->fetch($since)`, `$store->merge($name, $fresh)`, then read back `$store->all($name, sinceDays: 371)` for the render - window (53 weeks × 7 days) and merge into the returned array. + window (53 weeks × 7 days) — a `ContributionCollection` — and merge + its contributions into the returned array. - [ ] Call `$store->prune()` once per `aggregate()` call, after all providers have merged. - [ ] Keep the existing try/catch-and-log-per-provider behavior — a -- 2.54.0 From e2a324b74a4894df41ba0fd8015e7aec34367a81 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 12:23:31 +0200 Subject: [PATCH 27/42] chore(todo): expanded todo to fix a live bug --- TODO.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 22 deletions(-) diff --git a/TODO.md b/TODO.md index 8ce8925..c007d5e 100644 --- a/TODO.md +++ b/TODO.md @@ -110,35 +110,70 @@ Order matters — later steps assume earlier ones are done. - [x] Tests: `tests/Unit/Entity/ContributionTest.php` and `ContributionCollectionTest.php` — construction, iteration, `count()`. -## 4. Wire `$since` through providers +## 4. Wire `$since`/`$until` through providers -- [ ] `ProviderInterface::fetch()` → `fetch(?\DateTimeImmutable $since = null): array`. -- [ ] `GitHubProvider::fetch()` — use `$since ?? new \DateTimeImmutable('-365 days')` - for the GraphQL `from` param. -- [ ] `GitLabProvider::fetch()` — same, feeds the `after` query param - (this is what actually shrinks the pagination loop). -- [ ] `GiteaProvider::fetch()` — same, feeds the `$cutoff` timestamp filter. -- [ ] Update `GitHubProviderTest.php` (drop `GraphQLApiClientRegistryInterface` - stub setup entirely per step 1), `GitLabProviderTest.php`, - `GiteaProviderTest.php` — add a case asserting a passed `$since` narrows - the request window. +Root cause of the live `MaxExecutionTimeError` crash: every cache-miss +request re-fetches the full ~365-day history from every host, and +GitLab's pagination is unbounded and sequential. Bounding the fetch +window (step 5's 3-day trailing overlap) is what actually fixes it — a +PHP `max_execution_time` fatal can't be caught by `try/catch` at all, so +"catch it better" was never on the table. + +- [ ] `ProviderInterface::startFetch()` → `startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed` + (interface already split into `startFetch`/`resolveFetch` for + concurrency — this doc previously said `fetch()`, which predates that + split). +- [ ] `GitHubProvider::startFetch()` — already builds explicit `from`/`to` + GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded + `-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`. +- [ ] `GitLabProvider::startFetch()` — add a `before` query param alongside + the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`; + `$since` already flows into `after` (this is what actually shrinks the + pagination loop). +- [ ] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query + params (always returns full history); add an `$until` upper-bound + filter alongside the existing `$cutoff` lower bound. +- [ ] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`, + `GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until` + narrows the request window/query params. ## 5. Wire `ContributionStore` into `ContributionAggregator` +- [ ] Fix `ContributionStore` wiring in `config/services.yaml` first — + it's currently dead code (nothing calls it). The constructor default + `$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is + a plain PHP default, not a resolved container parameter; bind it + explicitly (same pattern as the provider bindings): + ```yaml + App\Service\ContributionStore: + arguments: + $dbPath: '%kernel.project_dir%/var/data/contributions.db' + $retentionDays: '%env(int:CONTRIBUTIONS_RETENTION_DAYS)%' + ``` + Add `env(CONTRIBUTIONS_RETENTION_DAYS): ''` to `parameters:` (empty → + casts to `0` → `prune()`'s existing `retentionDays === 0` check already + treats that as "keep forever"). Document the var in `.env`. - [ ] Inject `ContributionStore` into `ContributionAggregator`. -- [ ] Per configured provider: `$since = $store->latestDate($name)` → if - set, `(new \DateTimeImmutable($latest))->modify('-3 days')` (3-day - overlap for late corrections), else `null` (first run). -- [ ] `$fresh = $provider->fetch($since)`, `$store->merge($name, $fresh)`, - then read back `$store->all($name, sinceDays: 371)` for the render - window (53 weeks × 7 days) — a `ContributionCollection` — and merge - its contributions into the returned array. +- [ ] Per configured provider: `$latest = $store->latestDate($name)` → + `$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null` + (3-day overlap for late corrections — old stored days are immutable and + never re-fetched, only this trailing window + anything new hits the + network), else `null` (first run, provider's own default lookback). + `$until = null` (always "up to now" on the normal request path). +- [ ] `startFetch($since, $until)` / `resolveFetch()` as today, + `$store->merge($name, $fresh)` on success, then read back + `$store->all($name, sinceDays: 371)` for the render window + (53 weeks × 7 days) — a `ContributionCollection` — and merge its + contributions into the returned array (the store becomes the source + of truth for what gets rendered, not the fresh fetch alone). - [ ] Call `$store->prune()` once per `aggregate()` call, after all providers have merged. - [ ] Keep the existing try/catch-and-log-per-provider behavior — a provider failure leaves its DB history stale, doesn't break the render. -- [ ] Update `ContributionAggregatorTest.php` with store-interaction and - prune-call cases. +- [ ] Update `ContributionAggregatorTest.php` with store-interaction + (`latestDate` consulted, `merge` called, `all()` feeds the result, + `prune()` runs once) and a case confirming a provider failure leaves + other providers' stored data intact. ## 6. Docker / env @@ -150,7 +185,46 @@ Order matters — later steps assume earlier ones are done. - [ ] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default), same style as the existing `ALLOWED_HOSTS` comment. -## 7. PHPStan +## 9. `app:contributions:refetch` console command + +Manual escape hatch for forcing a full or ranged re-fetch (e.g. after +adding a new host, or if the store needs rebuilding) — bypasses step 5's +incremental `latestDate()` window entirely for the providers/range given. + +- [ ] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` + + constructor-promoted readonly properties (matches this codebase's + attribute-first style), using `SymfonyStyle` + ([console styling guide](https://symfony.com/doc/current/console/style.html)). + Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`, + same as `ContributionAggregator`) and `ContributionStore`. +- [ ] Options: `--provider=github,gitlab` (repeatable/comma-split, + restricts to named provider(s), default = all configured; unknown name + → `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default + today − 365 days), `--to=YYYY-MM-DD` (default today), `--all` (shorthand + for `--from` far enough back — e.g. 2005-01-01 — to mean "existing full + history"; combinable with `--provider`). +- [ ] **Batch by range**: split `[from, to]` into ≤365-day chunks + (GitHub's GraphQL `contributionsCollection` rejects windows over a + year; chunking also caps GitLab's pagination per call, so a big + `--all` re-fetch can't reintroduce the original runaway-pagination + timeout). Iterate chunks oldest-first. +- [ ] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)` + / `resolveFetch()`, `$store->merge($name, $result)` immediately (don't + accumulate all chunks in memory), `ProgressBar` across chunks. +- [ ] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`, + continue with remaining chunks/providers (same graceful-degradation + philosophy as `ContributionAggregator`). +- [ ] Finish with `$io->table(...)` summary (provider, days written, + chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit + `Command::SUCCESS` if at least one provider fully succeeded, else + `Command::FAILURE`. +- [ ] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php` + using `CommandTester` with fake `ProviderInterface` stubs and a + `:memory:` `ContributionStore` — assert chunking count for a >365-day + range, store ends up populated, unknown `--provider` name fails + cleanly, a provider throwing doesn't abort the others. + +## 10. PHPStan - [ ] `composer require --dev phpstan/phpstan` (plain PHPStan — no Symfony extension needed for this app's size). @@ -158,12 +232,13 @@ Order matters — later steps assume earlier ones are done. - [ ] Add composer script `"phpstan": "phpstan analyse"`. - [ ] Fix whatever level-8 flags on first run. -## 8. `CLAUDE.md` update +## 11. `CLAUDE.md` update - [ ] Architecture section: add the `ContributionStore` (SQLite) tier between providers and the renderer; note the two-tier cache (1h SVG cache → SQLite raw-data store → provider APIs). - [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`. +- [ ] Document `app:contributions:refetch` (options, batching behavior). - [ ] Development section: add `composer phpstan` next to the existing `vendor/bin/phpunit` commands. - [ ] Remove any remaining mentions of the two deleted bundles. @@ -181,3 +256,11 @@ Order matters — later steps assume earlier ones are done. are gone after the next refresh (`sqlite3 var/data/contributions.db "SELECT date FROM contributions ORDER BY date LIMIT 1"`). - `curl localhost:8080/health` still reports all configured providers healthy. +- `docker compose exec graph bin/console app:contributions:refetch --all` + populates `var/data/contributions.db` from empty; re-run with no flags + and confirm logs show only the trailing-window re-fetch. +- `docker compose exec graph bin/console app:contributions:refetch --provider=github --from=2020-01-01 --to=2023-01-01` + exercises multi-chunk batching. +- Confirm the original crash scenario is gone: an account with heavy + GitLab history no longer triggers `MaxExecutionTimeError` on a cold + cache (bounded by the 3-day trailing window, not full history). -- 2.54.0 From 095f3e0253eb9f8cf5111b2eebb0d2785482b7ae Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 12:25:00 +0200 Subject: [PATCH 28/42] feat(db): wired databas into services config --- config/services.yaml | 46 ++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index d381730..d12ac63 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,14 +1,14 @@ 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): '' + 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: @@ -17,27 +17,31 @@ services: public: false App\: - resource: '../src/' + resource: "../src/" exclude: - - '../src/Kernel.php' + - "../src/Kernel.php" _instanceof: App\Service\Provider\ProviderInterface: - tags: ['app.provider'] + tags: ["app.provider"] App\Service\Provider\GitHubProvider: arguments: - $username: '%env(GITHUB_USER)%' - $token: '%env(GITHUB_TOKEN)%' + $username: "%env(GITHUB_USER)%" + $token: "%env(GITHUB_TOKEN)%" App\Service\Provider\GitLabProvider: arguments: - $username: '%env(GITLAB_USER)%' - $token: '%env(GITLAB_TOKEN)%' - $baseUrl: '%env(GITLAB_URL)%' + $username: "%env(GITLAB_USER)%" + $token: "%env(GITLAB_TOKEN)%" + $baseUrl: "%env(GITLAB_URL)%" App\Service\Provider\GiteaProvider: arguments: - $username: '%env(GITEA_USER)%' - $token: '%env(GITEA_TOKEN)%' - $baseUrl: '%env(GITEA_URL)%' + $username: "%env(GITEA_USER)%" + $token: "%env(GITEA_TOKEN)%" + $baseUrl: "%env(GITEA_URL)%" + App\Service\ContributionStore: + arguments: + $dbPath: "%kernel.project_dir%/var/data/contributions.db" + $retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%" -- 2.54.0 From e7f14dfd35598a736cbaf35b1cf374e0befed100 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 12:27:42 +0200 Subject: [PATCH 29/42] chore(doc): documentet all public methods --- src/Service/ContributionStore.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Service/ContributionStore.php b/src/Service/ContributionStore.php index c12a32f..7c8fd77 100644 --- a/src/Service/ContributionStore.php +++ b/src/Service/ContributionStore.php @@ -13,6 +13,9 @@ class ContributionStore private PDO $pdo; private const int DAY_IN_SECONDS = 86400; + /** + * Opens (creating if needed) the SQLite store at $dbPath and ensures the schema exists. + */ public function __construct( private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db", private readonly ?int $retentionDays = null, @@ -38,6 +41,9 @@ class ContributionStore '); } + /** + * Inserts a contribution count, overwriting any existing count for the same provider/date. + */ public function add(string $provider, int $unixtime, int $count): void { $stmt = $this->pdo->prepare(' @@ -48,6 +54,9 @@ class ContributionStore $stmt->execute([$provider, $unixtime, $count]); } + /** + * Deletes the contribution row for the given provider/date, if any. + */ public function remove(string $provider, int $unixtime): void { $stmt = $this->pdo->prepare(' @@ -58,6 +67,8 @@ class ContributionStore } /** + * Upserts a batch of date => count pairs for a provider via repeated add() calls. + * * @param array $dateCounts unix timestamp => count */ public function merge(string $provider, array $dateCounts): void @@ -67,6 +78,9 @@ class ContributionStore } } + /** + * Returns the most recent stored unix timestamp for a provider, or null if it has no rows. + */ public function latestDate(string $provider): ?int { $stmt = $this->pdo->prepare('SELECT MAX(date) FROM contributions WHERE provider = ?'); @@ -76,6 +90,9 @@ class ContributionStore return $result !== null ? (int) $result : null; } + /** + * Returns all stored contributions for a provider, optionally limited to the last $sinceDays days. + */ public function all(string $provider, ?int $sinceDays = null): ContributionCollection { $sql = 'SELECT provider, date, count FROM contributions WHERE provider = ?'; @@ -90,13 +107,16 @@ class ContributionStore $stmt->execute($params); $contributions = array_map( - static fn (array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']), + static fn(array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']), $stmt->fetchAll(PDO::FETCH_ASSOC), ); return new ContributionCollection(...$contributions); } + /** + * Deletes rows older than the configured retention window; a no-op if retention is unset or 0. + */ public function prune(): void { if ($this->retentionDays === null || $this->retentionDays === 0) { -- 2.54.0 From 0d7cf68771ee8430e26032d3b6032d00c26b999b Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 12:43:33 +0200 Subject: [PATCH 30/42] chore(todo): checked php stan instalation and configuration. added document php stan erros as well to update readme docs --- TODO.md | 272 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 136 insertions(+), 136 deletions(-) diff --git a/TODO.md b/TODO.md index c007d5e..4ac298a 100644 --- a/TODO.md +++ b/TODO.md @@ -7,33 +7,32 @@ Order matters — later steps assume earlier ones are done. ## 1. Audit cleanup - [x] **Remove `eightpoints/guzzle-bundle` + `idci/graphql-client-bundle`.** - They exist only to build one near-static GitHub GraphQL query string - (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already - bypassed (comment at `GitHubProvider.php:76-77`). - - [x] Replace - `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain - `sprintf`/heredoc query string. Delete the two packages from - `composer.json`, `config/bundles.php`, and delete - `config/packages/eight_points_guzzle.yaml` + - `config/packages/idci_graphql_client.yaml`. Run `composer update` and - commit the regenerated `composer.lock`. - -- [x] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, - 53) and `GiteaProvider.php` (lines 42, 54) each call - `rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in - `fetch()`. Compute it once as a `private readonly string $baseUrl` in - the constructor instead. + They exist only to build one near-static GitHub GraphQL query string + (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already + bypassed (comment at `GitHubProvider.php:76-77`). + - [x] Replace + `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain + `sprintf`/heredoc query string. Delete the two packages from + `composer.json`, `config/bundles.php`, and delete + `config/packages/eight_points_guzzle.yaml` + + `config/packages/idci_graphql_client.yaml`. Run `composer update` and + commit the regenerated `composer.lock`. + +- [x] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, 53) and `GiteaProvider.php` (lines 42, 54) each call + `rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in + `fetch()`. Compute it once as a `private readonly string $baseUrl` in + the constructor instead. ## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4) - [x] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the - pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and - `_instanceof: App\Service\ProviderInterface`, left over from the - `Service/` → `Provider/`+`Renderer/` namespace reorg. Update all of - these to `App\Service\Provider\...`. Currently these bindings silently - no-op, and scalar constructor args can't autowire without them — any - edit to these constructors (steps 1, 4) needs this fixed first, or the - container fails to compile. + pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and + `_instanceof: App\Service\ProviderInterface`, left over from the + `Service/` → `Provider/`+`Renderer/` namespace reorg. Update all of + these to `App\Service\Provider\...`. Currently these bindings silently + no-op, and scalar constructor args can't autowire without them — any + edit to these constructors (steps 1, 4) needs this fixed first, or the + container fails to compile. ## 3. `ContributionStore` (SQLite via native PDO) @@ -46,10 +45,10 @@ Order matters — later steps assume earlier ones are done. > [SQLite datatypes (why `date` is `INTEGER`/unixtime, not `TEXT`)](https://www.sqlite.org/datatype3.html) - [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at - `%kernel.project_dir%/var/data/contributions.db` (configurable - constructor arg), table created lazily. **Schema note:** `date` is - stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the - current implementation: + `%kernel.project_dir%/var/data/contributions.db` (configurable + constructor arg), table created lazily. **Schema note:** `date` is + stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the + current implementation: ```sql CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, @@ -59,56 +58,56 @@ Order matters — later steps assume earlier ones are done. ) WITHOUT ROWID, STRICT; ``` - [x] `add(string $provider, int $unixtime, int $count): void` — plain - insert (done, needs test coverage — see below). + insert (done, needs test coverage — see below). - [x] Fix `add()`: currently a plain `INSERT`, so re-adding an existing - `(provider, date)` throws a unique-constraint violation instead of - upserting. Switch to - `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count` - (see SQLite upsert doc above), or keep `add()` insert-only and add a - separate `merge()` for the upsert case used by step 5. + `(provider, date)` throws a unique-constraint violation instead of + upserting. Switch to + `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count` + (see SQLite upsert doc above), or keep `add()` insert-only and add a + separate `merge()` for the upsert case used by step 5. - [x] `remove(string $provider, int $unixtime): void` — started, has a - bug: `DELETE contributions WHERE ...` is invalid SQL, missing the - `FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also - drop the `LIKE` on `provider` (exact match, use `=`) since it's an - unindexed wildcard scan for what should be an exact key lookup. + bug: `DELETE contributions WHERE ...` is invalid SQL, missing the + `FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also + drop the `LIKE` on `provider` (exact match, use `=`) since it's an + unindexed wildcard scan for what should be an exact key lookup. - [x] **`Contribution` entity.** Small immutable value object - (`src/Entity/Contribution.php`) wrapping one row: `provider` (string), - `date` (unix timestamp int, or `\DateTimeImmutable` — pick one and - use it consistently everywhere, including `add()`/`all()`), `count` - (int). Gives `ContributionStore::all()` and the aggregator something - typed to pass around instead of raw arrays/tuples. + (`src/Entity/Contribution.php`) wrapping one row: `provider` (string), + `date` (unix timestamp int, or `\DateTimeImmutable` — pick one and + use it consistently everywhere, including `add()`/`all()`), `count` + (int). Gives `ContributionStore::all()` and the aggregator something + typed to pass around instead of raw arrays/tuples. - [x] **`ContributionCollection`.** Typed collection - (`src/Entity/ContributionCollection.php`) wrapping - `array` — implement `IteratorAggregate` + `Countable` - at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php), - [`Countable`](https://www.php.net/manual/en/class.countable.php)) so - it can be `foreach`'d and `count()`'d like a normal array. This is - what `ContributionStore::all()` should return instead of a bare - `date => count` array. + (`src/Entity/ContributionCollection.php`) wrapping + `array` — implement `IteratorAggregate` + `Countable` + at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php), + [`Countable`](https://www.php.net/manual/en/class.countable.php)) so + it can be `foreach`'d and `count()`'d like a normal array. This is + what `ContributionStore::all()` should return instead of a bare + `date => count` array. - [x] `latestDate(string $provider): ?int` — `SELECT MAX(date) WHERE provider = ?` - (returns a unix timestamp, not a string, per the schema above). + (returns a unix timestamp, not a string, per the schema above). - [x] `merge(string $provider, array $dateCounts): void` — upsert via - `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`; - `$dateCounts` keyed by unix timestamp. + `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`; + `$dateCounts` keyed by unix timestamp. - [x] `all(string $provider, ?int $sinceDays = null): ContributionCollection` — - fetch all rows for a provider, or since a given range. `null` returns - full history (no arbitrary cutoff), a value filters to rows where - `date >= (now - sinceDays * 86400)`. **Bug:** current stub in - `ContributionStore.php:58` has invalid PHP (`$provider == null` instead - of `?string $provider = null` — this won't parse) and returns `void` - instead of `ContributionCollection`; fix the signature when implementing. + fetch all rows for a provider, or since a given range. `null` returns + full history (no arbitrary cutoff), a value filters to rows where + `date >= (now - sinceDays * 86400)`. **Bug:** current stub in + `ContributionStore.php:58` has invalid PHP (`$provider == null` instead + of `?string $provider = null` — this won't parse) and returns `void` + instead of `ContributionCollection`; fix the signature when implementing. - [x] `prune(): void` — `DELETE FROM contributions WHERE date < ?` using - the configured retention window (a unix timestamp cutoff); no-ops if - retention is unset/0. + the configured retention window (a unix timestamp cutoff); no-ops if + retention is unset/0. - [x] Constructor takes `?int $retentionDays` bound from new env var - `CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). *(constructor - param wired; the env binding itself is step 6's job.)* + `CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). _(constructor + param wired; the env binding itself is step 6's job.)_ - [x] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store & - retrieve, upsert overwrites existing date, `sinceDays` filtering, - `prune()` no-ops when retention unset, `prune()` deletes rows older - than the window when set. + retrieve, upsert overwrites existing date, `sinceDays` filtering, + `prune()` no-ops when retention unset, `prune()` deletes rows older + than the window when set. - [x] Tests: `tests/Unit/Entity/ContributionTest.php` and - `ContributionCollectionTest.php` — construction, iteration, `count()`. + `ContributionCollectionTest.php` — construction, iteration, `count()`. ## 4. Wire `$since`/`$until` through providers @@ -120,70 +119,70 @@ PHP `max_execution_time` fatal can't be caught by `try/catch` at all, so "catch it better" was never on the table. - [ ] `ProviderInterface::startFetch()` → `startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed` - (interface already split into `startFetch`/`resolveFetch` for - concurrency — this doc previously said `fetch()`, which predates that - split). + (interface already split into `startFetch`/`resolveFetch` for + concurrency — this doc previously said `fetch()`, which predates that + split). - [ ] `GitHubProvider::startFetch()` — already builds explicit `from`/`to` - GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded - `-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`. + GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded + `-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`. - [ ] `GitLabProvider::startFetch()` — add a `before` query param alongside - the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`; - `$since` already flows into `after` (this is what actually shrinks the - pagination loop). + the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`; + `$since` already flows into `after` (this is what actually shrinks the + pagination loop). - [ ] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query - params (always returns full history); add an `$until` upper-bound - filter alongside the existing `$cutoff` lower bound. + params (always returns full history); add an `$until` upper-bound + filter alongside the existing `$cutoff` lower bound. - [ ] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`, - `GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until` - narrows the request window/query params. + `GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until` + narrows the request window/query params. ## 5. Wire `ContributionStore` into `ContributionAggregator` - [ ] Fix `ContributionStore` wiring in `config/services.yaml` first — - it's currently dead code (nothing calls it). The constructor default - `$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is - a plain PHP default, not a resolved container parameter; bind it - explicitly (same pattern as the provider bindings): + it's currently dead code (nothing calls it). The constructor default + `$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is + a plain PHP default, not a resolved container parameter; bind it + explicitly (same pattern as the provider bindings): ```yaml App\Service\ContributionStore: - arguments: - $dbPath: '%kernel.project_dir%/var/data/contributions.db' - $retentionDays: '%env(int:CONTRIBUTIONS_RETENTION_DAYS)%' + arguments: + $dbPath: "%kernel.project_dir%/var/data/contributions.db" + $retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%" ``` Add `env(CONTRIBUTIONS_RETENTION_DAYS): ''` to `parameters:` (empty → casts to `0` → `prune()`'s existing `retentionDays === 0` check already treats that as "keep forever"). Document the var in `.env`. - [ ] Inject `ContributionStore` into `ContributionAggregator`. - [ ] Per configured provider: `$latest = $store->latestDate($name)` → - `$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null` - (3-day overlap for late corrections — old stored days are immutable and - never re-fetched, only this trailing window + anything new hits the - network), else `null` (first run, provider's own default lookback). - `$until = null` (always "up to now" on the normal request path). + `$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null` + (3-day overlap for late corrections — old stored days are immutable and + never re-fetched, only this trailing window + anything new hits the + network), else `null` (first run, provider's own default lookback). + `$until = null` (always "up to now" on the normal request path). - [ ] `startFetch($since, $until)` / `resolveFetch()` as today, - `$store->merge($name, $fresh)` on success, then read back - `$store->all($name, sinceDays: 371)` for the render window - (53 weeks × 7 days) — a `ContributionCollection` — and merge its - contributions into the returned array (the store becomes the source - of truth for what gets rendered, not the fresh fetch alone). + `$store->merge($name, $fresh)` on success, then read back + `$store->all($name, sinceDays: 371)` for the render window + (53 weeks × 7 days) — a `ContributionCollection` — and merge its + contributions into the returned array (the store becomes the source + of truth for what gets rendered, not the fresh fetch alone). - [ ] Call `$store->prune()` once per `aggregate()` call, after all - providers have merged. + providers have merged. - [ ] Keep the existing try/catch-and-log-per-provider behavior — a - provider failure leaves its DB history stale, doesn't break the render. + provider failure leaves its DB history stale, doesn't break the render. - [ ] Update `ContributionAggregatorTest.php` with store-interaction - (`latestDate` consulted, `merge` called, `all()` feeds the result, - `prune()` runs once) and a case confirming a provider failure leaves - other providers' stored data intact. + (`latestDate` consulted, `merge` called, `all()` feeds the result, + `prune()` runs once) and a case confirming a provider failure leaves + other providers' stored data intact. ## 6. Docker / env - [ ] `docker-compose.yml` — add a `data` named volume mounted at - `/app/var/data` (same pattern as `cache`/`logs`), and pass through - `CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`. + `/app/var/data` (same pattern as `cache`/`logs`), and pass through + `CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`. - [ ] `Dockerfile` — add `var/data` to the `mkdir -p` in the `final` stage - alongside `var/cache/prod/pools var/log`, owned by `app`. + alongside `var/cache/prod/pools var/log`, owned by `app`. - [ ] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default), - same style as the existing `ALLOWED_HOSTS` comment. + same style as the existing `ALLOWED_HOSTS` comment. ## 9. `app:contributions:refetch` console command @@ -192,55 +191,56 @@ adding a new host, or if the store needs rebuilding) — bypasses step 5's incremental `latestDate()` window entirely for the providers/range given. - [ ] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` + - constructor-promoted readonly properties (matches this codebase's - attribute-first style), using `SymfonyStyle` - ([console styling guide](https://symfony.com/doc/current/console/style.html)). - Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`, - same as `ContributionAggregator`) and `ContributionStore`. + constructor-promoted readonly properties (matches this codebase's + attribute-first style), using `SymfonyStyle` + ([console styling guide](https://symfony.com/doc/current/console/style.html)). + Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`, + same as `ContributionAggregator`) and `ContributionStore`. - [ ] Options: `--provider=github,gitlab` (repeatable/comma-split, - restricts to named provider(s), default = all configured; unknown name - → `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default - today − 365 days), `--to=YYYY-MM-DD` (default today), `--all` (shorthand - for `--from` far enough back — e.g. 2005-01-01 — to mean "existing full - history"; combinable with `--provider`). + restricts to named provider(s), default = all configured; unknown name + → `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default + today − 365 days), `--to=YYYY-MM-DD` (default today), `--all` (shorthand + for `--from` far enough back — e.g. 2005-01-01 — to mean "existing full + history"; combinable with `--provider`). - [ ] **Batch by range**: split `[from, to]` into ≤365-day chunks - (GitHub's GraphQL `contributionsCollection` rejects windows over a - year; chunking also caps GitLab's pagination per call, so a big - `--all` re-fetch can't reintroduce the original runaway-pagination - timeout). Iterate chunks oldest-first. + (GitHub's GraphQL `contributionsCollection` rejects windows over a + year; chunking also caps GitLab's pagination per call, so a big + `--all` re-fetch can't reintroduce the original runaway-pagination + timeout). Iterate chunks oldest-first. - [ ] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)` - / `resolveFetch()`, `$store->merge($name, $result)` immediately (don't - accumulate all chunks in memory), `ProgressBar` across chunks. + / `resolveFetch()`, `$store->merge($name, $result)` immediately (don't + accumulate all chunks in memory), `ProgressBar` across chunks. - [ ] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`, - continue with remaining chunks/providers (same graceful-degradation - philosophy as `ContributionAggregator`). + continue with remaining chunks/providers (same graceful-degradation + philosophy as `ContributionAggregator`). - [ ] Finish with `$io->table(...)` summary (provider, days written, - chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit - `Command::SUCCESS` if at least one provider fully succeeded, else - `Command::FAILURE`. + chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit + `Command::SUCCESS` if at least one provider fully succeeded, else + `Command::FAILURE`. - [ ] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php` - using `CommandTester` with fake `ProviderInterface` stubs and a - `:memory:` `ContributionStore` — assert chunking count for a >365-day - range, store ends up populated, unknown `--provider` name fails - cleanly, a provider throwing doesn't abort the others. + using `CommandTester` with fake `ProviderInterface` stubs and a + `:memory:` `ContributionStore` — assert chunking count for a >365-day + range, store ends up populated, unknown `--provider` name fails + cleanly, a provider throwing doesn't abort the others. ## 10. PHPStan -- [ ] `composer require --dev phpstan/phpstan` (plain PHPStan — no - Symfony extension needed for this app's size). -- [ ] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`. +- [x] `composer require --dev phpstan/phpstan` (plain PHPStan — no + Symfony extension needed for this app's size). +- [x] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`. - [ ] Add composer script `"phpstan": "phpstan analyse"`. -- [ ] Fix whatever level-8 flags on first run. +- [ ] Update README.md docs with new command and php stan static lintin. +- [ ] Document in PHP-Stan-Errors.md whatever level-8 flags on first run. ## 11. `CLAUDE.md` update - [ ] Architecture section: add the `ContributionStore` (SQLite) tier - between providers and the renderer; note the two-tier cache (1h SVG - cache → SQLite raw-data store → provider APIs). + between providers and the renderer; note the two-tier cache (1h SVG + cache → SQLite raw-data store → provider APIs). - [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`. - [ ] Document `app:contributions:refetch` (options, batching behavior). - [ ] Development section: add `composer phpstan` next to the existing - `vendor/bin/phpunit` commands. + `vendor/bin/phpunit` commands. - [ ] Remove any remaining mentions of the two deleted bundles. ## Verification -- 2.54.0 From 91bda21f8974c74a00a5defa0758761fe7def5d3 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 12:43:54 +0200 Subject: [PATCH 31/42] feat(phpstan): added php stan --- composer.json | 1 + composer.lock | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++- phpstan.neon | 5 ++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 phpstan.neon diff --git a/composer.json b/composer.json index eb44f46..91a56fa 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ } }, "require-dev": { + "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", "symfony/phpunit-bridge": "^7.4" }, diff --git a/composer.lock b/composer.lock index b621df9..0493984 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9e1d0aa40107f1c8cda205ed101547fe", + "content-hash": "70c6dbef453ea64accc644e5e5f9d7fc", "packages": [ { "name": "monolog/monolog", @@ -3320,6 +3320,70 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.2.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-05T06:31:06+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.12", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..606acab --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - src + - tests -- 2.54.0 From 41e88144a4da59cf030c33cf53ed8d5914131cf2 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 22:47:34 +0200 Subject: [PATCH 32/42] chore(namespace): rewirtten namspeace from app to GitContributionGraph. --- TODO.md | 2 +- bin/console | 2 +- composer.json | 4 +- config/routes.yaml | 2 +- config/services.yaml | 12 ++--- public/index.php | 2 +- public/worker.php | 2 +- src/Command/RefetchContributionsCommand.php | 54 +++++++++++++++++++ src/Controller/GraphController.php | 8 +-- src/Entity/Contribution.php | 2 +- src/Entity/ContributionCollection.php | 2 +- src/Kernel.php | 2 +- src/Service/ContributionAggregator.php | 4 +- src/Service/ContributionStore.php | 6 +-- src/Service/Provider/GitHubProvider.php | 2 +- src/Service/Provider/GitLabProvider.php | 2 +- src/Service/Provider/GiteaProvider.php | 2 +- src/Service/Provider/ProbeTrait.php | 2 +- src/Service/Provider/ProviderErrorCode.php | 2 +- .../Provider/ProviderHealthChecker.php | 2 +- src/Service/Provider/ProviderInterface.php | 2 +- src/Service/Provider/ProviderStatus.php | 2 +- src/Service/Provider/ProviderStatusType.php | 2 +- src/Service/Renderer/SvgRenderer.php | 2 +- .../Entity/ContributionCollectionTest.php | 6 +-- tests/Unit/Entity/ContributionTest.php | 4 +- .../Service/ContributionAggregatorTest.php | 6 +-- tests/Unit/Service/ContributionStoreTest.php | 4 +- .../Service/Provider/GitHubProviderTest.php | 4 +- .../Service/Provider/GitLabProviderTest.php | 4 +- .../Service/Provider/GiteaProviderTest.php | 4 +- .../Unit/Service/Provider/ProbeTraitTest.php | 10 ++-- .../Provider/ProviderHealthCheckerTest.php | 12 ++--- .../Service/Provider/ProviderStatusTest.php | 8 +-- .../Unit/Service/Renderer/SvgRendererTest.php | 4 +- 35 files changed, 122 insertions(+), 68 deletions(-) create mode 100644 src/Command/RefetchContributionsCommand.php diff --git a/TODO.md b/TODO.md index 4ac298a..10121f4 100644 --- a/TODO.md +++ b/TODO.md @@ -184,7 +184,7 @@ PHP `max_execution_time` fatal can't be caught by `try/catch` at all, so - [ ] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default), same style as the existing `ALLOWED_HOSTS` comment. -## 9. `app:contributions:refetch` console command +## 9. `graph:contributions:refetch` console command Manual escape hatch for forcing a full or ranged re-fetch (e.g. after adding a new host, or if the store needs rebuilding) — bypasses step 5's diff --git a/bin/console b/bin/console index f7ba6f7..f8e868b 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,7 @@ #!/usr/bin/env php io = new SymfonyStyle($input, $output); + } + + public function __invoke( + //TODO: check if its possible to add the preview with real strings from the providers. + #[Option("(repeatable/comma-split, restricts to named provider(s), default = all configured", "provider", "p", "")] + string $providers = "" + ) { + $requested = $providers === '' ? null : explode(',', $providers); + $byName = []; + + foreach ($this->providers as $provider) { + $byName[$provider->getName()] = $provider; + } + + if ($requested !== null) { + foreach ($requested as $name) { + if (!isset($byName[$name])) { + $this->io->error("Unknown provider: {$name}"); + return Command::FAILURE; + } + } + $byName = array_intersect_key($byName, array_flip($requested)); + } + } +} diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index 18b520f..6d716c9 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace App\Controller; +namespace GitContributionGraph\Controller; -use App\Service\ContributionAggregator; -use App\Service\Provider\ProviderHealthChecker; -use App\Service\Renderer\SvgRenderer; +use GitContributionGraph\Service\ContributionAggregator; +use GitContributionGraph\Service\Provider\ProviderHealthChecker; +use GitContributionGraph\Service\Renderer\SvgRenderer; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\RedirectResponse; diff --git a/src/Entity/Contribution.php b/src/Entity/Contribution.php index 9e04820..2e05227 100644 --- a/src/Entity/Contribution.php +++ b/src/Entity/Contribution.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Entity; +namespace GitContributionGraph\Entity; final class Contribution { diff --git a/src/Entity/ContributionCollection.php b/src/Entity/ContributionCollection.php index 60d26e7..eabbec0 100644 --- a/src/Entity/ContributionCollection.php +++ b/src/Entity/ContributionCollection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Entity; +namespace GitContributionGraph\Entity; /** * @implements \IteratorAggregate diff --git a/src/Kernel.php b/src/Kernel.php index 779cd1f..e8ad584 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -1,6 +1,6 @@ Date: Sun, 12 Jul 2026 23:42:41 +0200 Subject: [PATCH 33/42] feat(store): fetch contributions incrementally and persist history in SQLite Bound each provider's startFetch()/resolveFetch() to an optional since/until window, wire a SQLite-backed ContributionStore into ContributionAggregator (fetch only the trailing window past the last stored date, merge into the store, prune by CONTRIBUTIONS_RETENTION_DAYS), and add a graph:contributions:refetch command to force a full or ranged re-fetch in <=365-day chunks. This is the root fix for the full 365-day-refetch timeout that used to hit on every cache miss. --- .env | 4 + Dockerfile | 2 +- config/services.yaml | 1 + docker-compose.yml | 3 + src/Command/RefetchContributionsCommand.php | 177 ++++++++++++++++-- src/Service/ContributionAggregator.php | 62 ++++-- src/Service/Provider/GitHubProvider.php | 12 +- src/Service/Provider/GitLabProvider.php | 42 +++-- src/Service/Provider/GiteaProvider.php | 32 +++- src/Service/Provider/ProviderInterface.php | 22 ++- .../RefetchContributionsCommandTest.php | 149 +++++++++++++++ .../Service/ContributionAggregatorTest.php | 141 +++++++++++--- .../Service/Provider/GitHubProviderTest.php | 24 +++ .../Service/Provider/GitLabProviderTest.php | 24 +++ .../Service/Provider/GiteaProviderTest.php | 41 ++++ .../Unit/Service/Provider/ProbeTraitTest.php | 2 +- 16 files changed, 642 insertions(+), 96 deletions(-) create mode 100644 tests/Unit/Command/RefetchContributionsCommandTest.php diff --git a/.env b/.env index 008ef58..57fdf5e 100644 --- a/.env +++ b/.env @@ -19,3 +19,7 @@ GITLAB_URL= GITEA_USER= GITEA_TOKEN= GITEA_URL= + +# Number of days of contribution history to keep in var/data/contributions.db. +# 0 (or unset) keeps history forever. +CONTRIBUTIONS_RETENTION_DAYS=0 diff --git a/Dockerfile b/Dockerfile index bc313f6..6abde98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,7 @@ COPY --link docker/frankenphp/Caddyfile /etc/caddy/Caddyfile COPY --link docker/php/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/ RUN chmod +x bin/console && \ - mkdir -p var/cache/prod/pools var/log /config/caddy /data/caddy && \ + mkdir -p var/cache/prod/pools var/log var/data /config/caddy /data/caddy && \ chown -R app:app /app /config /data USER app diff --git a/config/services.yaml b/config/services.yaml index fccfd34..4f76ae9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,7 @@ parameters: env(GITEA_USER): "" env(GITEA_TOKEN): "" env(GITEA_URL): "" + env(CONTRIBUTIONS_RETENTION_DAYS): "0" services: _defaults: diff --git a/docker-compose.yml b/docker-compose.yml index 81d382c..98a613a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,9 +20,11 @@ services: GITEA_USER: "${GITEA_USER:-}" GITEA_TOKEN: "${GITEA_TOKEN:-}" GITEA_URL: "${GITEA_URL:-}" + CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}" volumes: - cache:/app/var/cache/prod/pools - logs:/app/var/log + - data:/app/var/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s @@ -33,3 +35,4 @@ services: volumes: cache: logs: + data: diff --git a/src/Command/RefetchContributionsCommand.php b/src/Command/RefetchContributionsCommand.php index 8724a6e..8935ba2 100644 --- a/src/Command/RefetchContributionsCommand.php +++ b/src/Command/RefetchContributionsCommand.php @@ -4,51 +4,188 @@ declare(strict_types=1); namespace GitContributionGraph\Command; +use GitContributionGraph\Service\ContributionStore; +use GitContributionGraph\Service\Provider\ProviderInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; -#[AsCommand(name: 'graph:contributions:refetch')] -class RefetchContributionsCommand extends Command +#[AsCommand( + name: 'graph:contributions:refetch', + description: 'Force a full or ranged re-fetch of contribution history into the store.', +)] +final class RefetchContributionsCommand extends Command { + private const MAX_CHUNK_DAYS = 365; + private const ALL_SINCE = '2005-01-01'; + private SymfonyStyle $io; + + /** @param iterable $providers */ public function __construct( #[AutowireIterator('app.provider')] private readonly iterable $providers, + private readonly ContributionStore $store, ) { parent::__construct(); } - - public function initialize(InputInterface $input, OutputInterface $output) + /** Symfony console lifecycle hook: sets up the styled I/O helper used throughout the command. */ + public function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); } + /** + * Bypasses the aggregator's incremental trailing-window fetch to re-pull a full or explicit + * date range for one, several, or all configured providers, chunked into ≤365-day windows. + * + * @param string $provider comma-separated provider names to refetch; empty = all configured + * @param string $from start date (YYYY-MM-DD); empty defaults to 365 days ago + * @param string $to end date (YYYY-MM-DD); empty defaults to today + * @param bool $all shorthand for --from=2005-01-01 + * @return int Command::SUCCESS if at least one provider refetched cleanly, else Command::FAILURE + */ public function __invoke( - //TODO: check if its possible to add the preview with real strings from the providers. - #[Option("(repeatable/comma-split, restricts to named provider(s), default = all configured", "provider", "p", "")] - string $providers = "" - ) { - $requested = $providers === '' ? null : explode(',', $providers); - $byName = []; - - foreach ($this->providers as $provider) { - $byName[$provider->getName()] = $provider; + #[Option('Comma-separated provider names to refetch (default: all configured)', 'provider', 'p')] + string $provider = '', + #[Option('Start date (YYYY-MM-DD), default: 365 days ago', 'from')] + string $from = '', + #[Option('End date (YYYY-MM-DD), default: today', 'to')] + string $to = '', + #[Option('Refetch full history (from ' . self::ALL_SINCE . ')', 'all')] + bool $all = false, + ): int { + $byName = $this->resolveProviders($provider); + if ($byName === null) { + return Command::FAILURE; } - if ($requested !== null) { - foreach ($requested as $name) { - if (!isset($byName[$name])) { - $this->io->error("Unknown provider: {$name}"); - return Command::FAILURE; - } + $fromSpec = match (true) { + $all => self::ALL_SINCE, + $from !== '' => $from, + default => '-365 days', + }; + + $fromDate = new \DateTimeImmutable($fromSpec); + $toDate = new \DateTimeImmutable($to !== '' ? $to : 'today'); + $chunks = $this->chunk($fromDate, $toDate); + + $summary = []; + $succeeded = 0; + + foreach ($byName as $name => $providerInstance) { + [$daysWritten, $errors] = $this->refetchProvider($name, $providerInstance, $chunks); + + if ($errors === 0) { + $succeeded++; } - $byName = array_intersect_key($byName, array_flip($requested)); + + $summary[] = [$name, $daysWritten, count($chunks), $errors]; } + + $this->io->table(['Provider', 'Days written', 'Chunks fetched', 'Errors'], $summary); + + if ($succeeded === 0) { + $this->io->error('No provider completed successfully.'); + + return Command::FAILURE; + } + + $this->io->success(sprintf('%d of %d provider(s) refetched successfully.', $succeeded, count($byName))); + + return Command::SUCCESS; + } + + /** + * @param list $chunks + * + * @return array{0: int, 1: int} days written, errors + */ + private function refetchProvider(string $name, ProviderInterface $provider, array $chunks): array + { + $this->io->section("Refetching {$name}"); + + $progress = new ProgressBar($this->io, count($chunks)); + $progress->start(); + + $daysWritten = 0; + $errors = 0; + + foreach ($chunks as [$chunkFrom, $chunkTo]) { + try { + $fresh = $provider->resolveFetch($provider->startFetch($chunkFrom, $chunkTo)); + + $dateCounts = []; + foreach ($fresh as $date => $count) { + $dateCounts[(new \DateTimeImmutable($date))->getTimestamp()] = $count; + } + + $this->store->merge($name, $dateCounts); + $daysWritten += count($dateCounts); + } catch (\Throwable $e) { + $errors++; + $this->io->warning("{$name}: {$e->getMessage()}"); + } + + $progress->advance(); + } + + $progress->finish(); + $this->io->newLine(2); + + return [$daysWritten, $errors]; + } + + /** + * @return array|null null when an unknown provider name was requested + */ + private function resolveProviders(string $requested): ?array + { + $byName = []; + foreach ($this->providers as $providerInstance) { + $byName[$providerInstance->getName()] = $providerInstance; + } + + if ($requested === '') { + return array_filter($byName, static fn (ProviderInterface $p): bool => $p->isConfigured()); + } + + $names = explode(',', $requested); + foreach ($names as $name) { + if (!isset($byName[$name])) { + $this->io->error("Unknown provider: {$name}"); + + return null; + } + } + + return array_intersect_key($byName, array_flip($names)); + } + + /** + * @return list + */ + private function chunk(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $chunks = []; + $cursor = $from; + + while ($cursor < $to) { + $chunkEnd = min($cursor->modify('+' . (self::MAX_CHUNK_DAYS - 1) . ' days'), $to); + $chunks[] = [$cursor, $chunkEnd]; + $cursor = $chunkEnd->modify('+1 day'); + } + + if ($chunks === []) { + $chunks[] = [$from, $to]; + } + + return $chunks; } } diff --git a/src/Service/ContributionAggregator.php b/src/Service/ContributionAggregator.php index 4bdd2f7..7aed7ce 100644 --- a/src/Service/ContributionAggregator.php +++ b/src/Service/ContributionAggregator.php @@ -10,25 +10,64 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; final class ContributionAggregator { + private const RENDER_WINDOW_DAYS = 371; + private const OVERLAP_DAYS = 3; + + /** @param iterable $providers */ public function __construct( #[AutowireIterator('app.provider')] private readonly iterable $providers, + private readonly ContributionStore $store, private readonly LoggerInterface $logger, ) {} - /** @return array */ + /** + * Incrementally fetches fresh contributions from each configured provider, merges them into + * the store, and returns the rolling render window summed across providers. + * + * For each provider, only data since its last stored date (minus a trailing overlap) is + * fetched; a provider with no stored history yet fetches its full default range. Fetches are + * split into start/resolve phases so all providers' requests are in flight concurrently. + * Failures are logged and the affected provider is skipped, not fatal to the others. + * + * @return array date (Y-m-d) => contribution count, summed across all providers + */ public function aggregate(): array { - $pending = []; + $configured = []; /** @var ProviderInterface $provider */ foreach ($this->providers as $provider) { - if (!$provider->isConfigured()) { - continue; + if ($provider->isConfigured()) { + $configured[] = $provider; } + } + + $pending = []; + + foreach ($configured as $provider) { + $latest = $this->store->latestDate($provider->getName()); + $since = $latest !== null + ? (new \DateTimeImmutable('@' . $latest))->modify('-' . self::OVERLAP_DAYS . ' days') + : null; try { - $pending[] = [$provider, $provider->startFetch()]; + $pending[] = [$provider, $provider->startFetch($since)]; + } catch (\Throwable $e) { + $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]); + } + } + + foreach ($pending as [$provider, $handle]) { + try { + $fresh = $provider->resolveFetch($handle); + + $dateCounts = []; + foreach ($fresh as $date => $count) { + $dateCounts[(new \DateTimeImmutable($date))->getTimestamp()] = $count; + } + + $this->store->merge($provider->getName(), $dateCounts); } catch (\Throwable $e) { $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]); } @@ -36,16 +75,15 @@ final class ContributionAggregator $contributions = []; - foreach ($pending as [$provider, $handle]) { - try { - foreach ($provider->resolveFetch($handle) 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]); + foreach ($configured as $provider) { + foreach ($this->store->all($provider->getName(), sinceDays: self::RENDER_WINDOW_DAYS) as $contribution) { + $date = (new \DateTimeImmutable('@' . $contribution->date))->format('Y-m-d'); + $contributions[$date] = ($contributions[$date] ?? 0) + $contribution->count; } } + $this->store->prune(); + return $contributions; } } diff --git a/src/Service/Provider/GitHubProvider.php b/src/Service/Provider/GitHubProvider.php index 99f69fb..2a06968 100644 --- a/src/Service/Provider/GitHubProvider.php +++ b/src/Service/Provider/GitHubProvider.php @@ -44,12 +44,13 @@ final class GitHubProvider implements ProviderInterface ])->getContent(); } - public function startFetch(): ResponseInterface + /** Fires the GraphQL contributionsCollection query bounded by $since/$until. */ + public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): ResponseInterface { $this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]); - $from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); - $to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); + $from = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); + $to = ($until ?? new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); $query = sprintf( 'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }', @@ -71,7 +72,10 @@ final class GitHubProvider implements ProviderInterface } /** - * @return array date (Y-m-d) => contribution count + * Parses the GraphQL response into a per-day count map, skipping zero-count days. + * + * @param ResponseInterface $handle the response returned by startFetch() + * @return array date (Y-m-d) => contribution count */ public function resolveFetch(mixed $handle): array { diff --git a/src/Service/Provider/GitLabProvider.php b/src/Service/Provider/GitLabProvider.php index 3d8d07c..90ee566 100644 --- a/src/Service/Provider/GitLabProvider.php +++ b/src/Service/Provider/GitLabProvider.php @@ -50,16 +50,18 @@ final class GitLabProvider implements ProviderInterface } /** - * @return array{baseUrl: string, userId: int, after: string, page: int, response: ResponseInterface} + * Resolves the username to a numeric user ID, then fires the first page of the events request. + * + * @return array{baseUrl: string, userId: int, after: string, before: ?string, page: int, response: ResponseInterface} * * ponytail: the user-id lookup and event pagination stay sequential within * this one provider (each page depends on the previous). Parallelism here * only spans across providers; revisit only if GitLab pagination itself * becomes the bottleneck. */ - public function startFetch(): array + public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array { - + $this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]); $userResponse = $this->client->request('GET', "$this->baseUrl/api/v4/users", [ @@ -72,26 +74,31 @@ final class GitLabProvider implements ProviderInterface throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $this->baseUrl"); } $userId = $users[0]['id']; - $after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d'); + $after = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d'); + $before = $until?->format('Y-m-d'); + + $query = ['after' => $after, 'per_page' => 100, 'page' => 1]; + if ($before !== null) { + $query['before'] = $before; + } $response = $this->client->request('GET', "$this->baseUrl/api/v4/users/$userId/events", [ 'headers' => ['PRIVATE-TOKEN' => $this->token], - 'query' => [ - 'after' => $after, - 'per_page' => 100, - 'page' => 1, - ], + 'query' => $query, ]); - return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response]; + return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'before' => $before, 'page' => 1, 'response' => $response]; } /** - * @return array date (Y-m-d) => event count + * Follows pagination (100 events/page) until a short page is returned, counting events per day. + * + * @param array{baseUrl: string, userId: int, after: string, before: ?string, page: int, response: ResponseInterface} $handle + * @return array date (Y-m-d) => event count */ public function resolveFetch(mixed $handle): array { - ['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => $page, 'response' => $response] = $handle; + ['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'before' => $before, 'page' => $page, 'response' => $response] = $handle; $result = []; @@ -106,13 +113,14 @@ final class GitLabProvider implements ProviderInterface $page++; if (count($events) === 100) { + $query = ['after' => $after, 'per_page' => 100, 'page' => $page]; + if ($before !== null) { + $query['before'] = $before; + } + $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [ 'headers' => ['PRIVATE-TOKEN' => $this->token], - 'query' => [ - 'after' => $after, - 'per_page' => 100, - 'page' => $page, - ], + 'query' => $query, ]); } } while (count($events) === 100); diff --git a/src/Service/Provider/GiteaProvider.php b/src/Service/Provider/GiteaProvider.php index 3863203..3e29d2a 100644 --- a/src/Service/Provider/GiteaProvider.php +++ b/src/Service/Provider/GiteaProvider.php @@ -51,31 +51,45 @@ final class GiteaProvider implements ProviderInterface ])->getContent(); } - public function startFetch(): ResponseInterface + /** + * Fires a single heatmap request; $since/$until are carried through unused for client-side filtering in resolveFetch(). + * + * @return array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable} + */ + public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array { - - $this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]); - return $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [ + $response = $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [ 'headers' => ['Authorization' => "token {$this->token}"], ]); + + return ['response' => $response, 'since' => $since, 'until' => $until]; } /** - * @return array date (Y-m-d) => contribution count + * Filters the heatmap entries to the [$since, $until] window and sums contributions per day. + * + * @param array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable} $handle + * @return array date (Y-m-d) => contribution count */ public function resolveFetch(mixed $handle): array { - /** @var ResponseInterface $handle */ - $data = $handle->toArray(); - $cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp(); - $result = []; + ['response' => $response, 'since' => $since, 'until' => $until] = $handle; + + /** @var ResponseInterface $response */ + $data = $response->toArray(); + $cutoff = ($since ?? new \DateTimeImmutable('-365 days'))->getTimestamp(); + $ceiling = $until?->getTimestamp(); + $result = []; foreach ($data as $entry) { if ($entry['timestamp'] < $cutoff) { continue; } + if ($ceiling !== null && $entry['timestamp'] > $ceiling) { + continue; + } $date = date('Y-m-d', $entry['timestamp']); $result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions']; } diff --git a/src/Service/Provider/ProviderInterface.php b/src/Service/Provider/ProviderInterface.php index 2daa0fe..dad0613 100644 --- a/src/Service/Provider/ProviderInterface.php +++ b/src/Service/Provider/ProviderInterface.php @@ -6,17 +6,33 @@ namespace GitContributionGraph\Service\Provider; interface ProviderInterface { - /** Fire the HTTP request(s) without blocking; returns an opaque handle for resolveFetch(). */ - public function startFetch(): mixed; + /** + * Fire the HTTP request(s) without blocking; the result is read later by + * resolveFetch() so multiple providers' requests can be in flight at once. + * + * @param ?\DateTimeImmutable $since lower bound (inclusive); defaults to 365 days ago + * @param ?\DateTimeImmutable $until upper bound (inclusive); defaults to now + * @return mixed opaque handle to pass into resolveFetch() + */ + public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed; - /** @return array date (Y-m-d) => contribution count */ + /** + * Block on the handle from startFetch() and parse it into a per-day count map. + * + * @param mixed $handle the value returned by startFetch() + * @return array date (Y-m-d) => contribution count + */ public function resolveFetch(mixed $handle): array; + /** Whether the required credentials/URL for this provider are all set. */ public function isConfigured(): bool; + /** Short lowercase identifier for this provider, e.g. "github". */ public function getName(): string; + /** Check reachability/credentials without fetching contribution data; used by the /health endpoint. */ public function probe(): ProviderStatus; + /** Make a lightweight authenticated request to verify the provider is reachable; throws on failure. */ public function ping(): void; } diff --git a/tests/Unit/Command/RefetchContributionsCommandTest.php b/tests/Unit/Command/RefetchContributionsCommandTest.php new file mode 100644 index 0000000..cbc805d --- /dev/null +++ b/tests/Unit/Command/RefetchContributionsCommandTest.php @@ -0,0 +1,149 @@ + $fresh */ + private function makeProvider( + string $name, + bool $configured = true, + ?array $fresh = null, + ?\Throwable $throws = null, + ): ProviderInterface { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('getName')->willReturn($name); + $provider->method('isConfigured')->willReturn($configured); + + if ($throws !== null) { + $provider->method('startFetch')->willThrowException($throws); + } elseif ($fresh !== null) { + $provider->method('resolveFetch')->willReturn($fresh); + } + + return $provider; + } + + /** @param iterable $providers */ + private function makeTester(iterable $providers, ContributionStore $store): CommandTester + { + $command = new RefetchContributionsCommand($providers, $store); + + $application = new Application(); + $application->add($command); + + return new CommandTester($application->find('graph:contributions:refetch')); + } + + #[Test] + public function it_refetches_the_default_365_day_window_for_all_configured_providers(): void + { + $store = new ContributionStore(':memory:'); + $provider = $this->makeProvider('github', fresh: ['2024-06-10' => 3]); + + $tester = $this->makeTester([$provider], $store); + $exitCode = $tester->execute([]); + + $this->assertSame(0, $exitCode); + $this->assertCount(1, $store->all('github')); + } + + #[Test] + public function it_skips_unconfigured_providers_by_default(): void + { + $store = new ContributionStore(':memory:'); + $provider = $this->makeProvider('github', configured: false); + + $tester = $this->makeTester([$provider], $store); + $tester->execute([]); + + $this->assertCount(0, $store->all('github')); + } + + #[Test] + public function it_fails_cleanly_on_an_unknown_provider_name(): void + { + $store = new ContributionStore(':memory:'); + $provider = $this->makeProvider('github'); + + $tester = $this->makeTester([$provider], $store); + $exitCode = $tester->execute(['--provider' => 'bogus']); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Unknown provider: bogus', $tester->getDisplay()); + } + + #[Test] + public function it_restricts_to_the_named_provider(): void + { + $store = new ContributionStore(':memory:'); + $github = $this->makeProvider('github', fresh: ['2024-06-10' => 1]); + $gitlab = $this->makeProvider('gitlab', fresh: ['2024-06-10' => 1]); + + $tester = $this->makeTester([$github, $gitlab], $store); + $tester->execute(['--provider' => 'github']); + + $this->assertCount(1, $store->all('github')); + $this->assertCount(0, $store->all('gitlab')); + } + + #[Test] + public function it_continues_with_remaining_providers_when_one_throws(): void + { + $store = new ContributionStore(':memory:'); + $failing = $this->makeProvider('gitlab', throws: new \RuntimeException('boom')); + $healthy = $this->makeProvider('github', fresh: ['2024-06-10' => 2]); + + $tester = $this->makeTester([$failing, $healthy], $store); + $exitCode = $tester->execute([]); + + $this->assertSame(0, $exitCode); + $this->assertCount(1, $store->all('github')); + $this->assertCount(0, $store->all('gitlab')); + } + + #[Test] + public function it_fails_when_every_provider_errors(): void + { + $store = new ContributionStore(':memory:'); + $failing = $this->makeProvider('github', throws: new \RuntimeException('boom')); + + $tester = $this->makeTester([$failing], $store); + $exitCode = $tester->execute([]); + + $this->assertSame(1, $exitCode); + } + + #[Test] + public function it_splits_a_multi_year_range_into_365_day_chunks(): void + { + $store = new ContributionStore(':memory:'); + $calls = 0; + + $provider = $this->createStub(ProviderInterface::class); + $provider->method('getName')->willReturn('github'); + $provider->method('isConfigured')->willReturn(true); + $provider->method('resolveFetch')->willReturnCallback(function () use (&$calls): array { + $calls++; + + return []; + }); + + $tester = $this->makeTester([$provider], $store); + $tester->execute(['--from' => '2020-01-01', '--to' => '2023-01-01']); + + $this->assertGreaterThan(1, $calls); + } +} diff --git a/tests/Unit/Service/ContributionAggregatorTest.php b/tests/Unit/Service/ContributionAggregatorTest.php index 5120026..9c939c3 100644 --- a/tests/Unit/Service/ContributionAggregatorTest.php +++ b/tests/Unit/Service/ContributionAggregatorTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace GitContributionGraph\Tests\Unit\Service; use GitContributionGraph\Service\ContributionAggregator; +use GitContributionGraph\Service\ContributionStore; use GitContributionGraph\Service\Provider\ProviderInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -15,16 +16,31 @@ use Psr\Log\LoggerInterface; final class ContributionAggregatorTest extends TestCase { private LoggerInterface $logger; + private ContributionStore $store; protected function setUp(): void { $this->logger = $this->createStub(LoggerInterface::class); + $this->store = new ContributionStore(':memory:'); + } + + /** @param ?array $fresh */ + private function makeProvider(string $name, bool $configured = true, ?array $fresh = null): ProviderInterface + { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('getName')->willReturn($name); + $provider->method('isConfigured')->willReturn($configured); + if ($fresh !== null) { + $provider->method('resolveFetch')->willReturn($fresh); + } + + return $provider; } #[Test] public function it_returns_empty_array_when_no_providers_are_given(): void { - $aggregator = new ContributionAggregator([], $this->logger); + $aggregator = new ContributionAggregator([], $this->store, $this->logger); $result = $aggregator->aggregate(); @@ -34,10 +50,9 @@ final class ContributionAggregatorTest extends TestCase #[Test] public function it_skips_unconfigured_providers(): void { - $provider = $this->createStub(ProviderInterface::class); - $provider->method('isConfigured')->willReturn(false); + $provider = $this->makeProvider('github', configured: false); - $aggregator = new ContributionAggregator([$provider], $this->logger); + $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger); $result = $aggregator->aggregate(); @@ -47,69 +62,68 @@ final class ContributionAggregatorTest extends TestCase #[Test] public function it_returns_contributions_from_a_configured_provider(): void { - $provider = $this->createStub(ProviderInterface::class); - $provider->method('isConfigured')->willReturn(true); - $provider->method('resolveFetch')->willReturn(['2024-01-01' => 3]); + $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); + $provider = $this->makeProvider('github', fresh: [$date => 3]); - $aggregator = new ContributionAggregator([$provider], $this->logger); + $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger); $result = $aggregator->aggregate(); - $this->assertSame(['2024-01-01' => 3], $result); + $this->assertSame([$date => 3], $result); } #[Test] public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void { - $providerA = $this->createStub(ProviderInterface::class); - $providerA->method('isConfigured')->willReturn(true); - $providerA->method('resolveFetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]); + $dateA = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); + $dateB = (new \DateTimeImmutable('-2 days'))->format('Y-m-d'); - $providerB = $this->createStub(ProviderInterface::class); - $providerB->method('isConfigured')->willReturn(true); - $providerB->method('resolveFetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]); + $providerA = $this->makeProvider('github', fresh: [$dateA => 3, $dateB => 1]); + $providerB = $this->makeProvider('gitlab', fresh: [$dateA => 2]); - $aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger); + $aggregator = new ContributionAggregator([$providerA, $providerB], $this->store, $this->logger); $result = $aggregator->aggregate(); - $this->assertSame(['2024-01-01' => 5, '2024-01-02' => 1, '2024-01-03' => 5], $result); + $this->assertSame([$dateB => 1, $dateA => 5], $result); } #[Test] public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void { + $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); + $failing = $this->createStub(ProviderInterface::class); + $failing->method('getName')->willReturn('gitlab'); $failing->method('isConfigured')->willReturn(true); $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error')); - $healthy = $this->createStub(ProviderInterface::class); - $healthy->method('isConfigured')->willReturn(true); - $healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]); + $healthy = $this->makeProvider('github', fresh: [$date => 7]); - $aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); + $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger); $result = $aggregator->aggregate(); - $this->assertSame(['2024-01-01' => 7], $result); + $this->assertSame([$date => 7], $result); } #[Test] public function it_continues_fetching_remaining_providers_when_one_throws_on_resolve(): void { + $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); + $failing = $this->createStub(ProviderInterface::class); + $failing->method('getName')->willReturn('gitlab'); $failing->method('isConfigured')->willReturn(true); $failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error')); - $healthy = $this->createStub(ProviderInterface::class); - $healthy->method('isConfigured')->willReturn(true); - $healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]); + $healthy = $this->makeProvider('github', fresh: [$date => 7]); - $aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); + $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger); $result = $aggregator->aggregate(); - $this->assertSame(['2024-01-01' => 7], $result); + $this->assertSame([$date => 7], $result); } #[Test] @@ -119,10 +133,11 @@ final class ContributionAggregatorTest extends TestCase $logger->expects($this->once())->method('warning'); $provider = $this->createStub(ProviderInterface::class); + $provider->method('getName')->willReturn('github'); $provider->method('isConfigured')->willReturn(true); $provider->method('startFetch')->willThrowException(new \RuntimeException('fail')); - (new ContributionAggregator([$provider], $logger))->aggregate(); + (new ContributionAggregator([$provider], $this->store, $logger))->aggregate(); } #[Test] @@ -132,9 +147,77 @@ final class ContributionAggregatorTest extends TestCase $logger->expects($this->once())->method('warning'); $provider = $this->createStub(ProviderInterface::class); + $provider->method('getName')->willReturn('github'); $provider->method('isConfigured')->willReturn(true); $provider->method('resolveFetch')->willThrowException(new \RuntimeException('fail')); - (new ContributionAggregator([$provider], $logger))->aggregate(); + (new ContributionAggregator([$provider], $this->store, $logger))->aggregate(); + } + + #[Test] + public function it_consults_latest_date_and_narrows_the_since_window_by_three_days(): void + { + $latest = (new \DateTimeImmutable('-30 days'))->getTimestamp(); + $this->store->add('github', $latest, 1); + + $provider = $this->createMock(ProviderInterface::class); + $provider->method('getName')->willReturn('github'); + $provider->method('isConfigured')->willReturn(true); + $provider->expects($this->once()) + ->method('startFetch') + ->with($this->callback( + fn (?\DateTimeImmutable $since): bool => $since !== null + && $since->getTimestamp() === $latest - 3 * 86400 + )) + ->willReturn(null); + $provider->method('resolveFetch')->willReturn([]); + + (new ContributionAggregator([$provider], $this->store, $this->logger))->aggregate(); + } + + #[Test] + public function it_merges_freshly_fetched_contributions_into_the_store(): void + { + $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); + $provider = $this->makeProvider('github', fresh: [$date => 4]); + + (new ContributionAggregator([$provider], $this->store, $this->logger))->aggregate(); + + $stored = iterator_to_array($this->store->all('github')); + $this->assertCount(1, $stored); + $this->assertSame(4, $stored[0]->count); + } + + #[Test] + public function it_prunes_the_store_once_per_aggregate_call(): void + { + $store = $this->createMock(ContributionStore::class); + $store->method('latestDate')->willReturn(null); + $store->method('all')->willReturn(new \GitContributionGraph\Entity\ContributionCollection()); + $store->expects($this->once())->method('prune'); + + $provider = $this->makeProvider('github', fresh: []); + + (new ContributionAggregator([$provider], $store, $this->logger))->aggregate(); + } + + #[Test] + public function it_leaves_other_providers_stored_data_intact_when_one_provider_fails(): void + { + $date = (new \DateTimeImmutable('-1 day'))->getTimestamp(); + $this->store->add('gitlab', $date, 9); + + $failing = $this->createStub(ProviderInterface::class); + $failing->method('getName')->willReturn('gitlab'); + $failing->method('isConfigured')->willReturn(true); + $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error')); + + $healthy = $this->makeProvider('github', fresh: []); + + (new ContributionAggregator([$failing, $healthy], $this->store, $this->logger))->aggregate(); + + $stored = iterator_to_array($this->store->all('gitlab')); + $this->assertCount(1, $stored); + $this->assertSame(9, $stored[0]->count); } } diff --git a/tests/Unit/Service/Provider/GitHubProviderTest.php b/tests/Unit/Service/Provider/GitHubProviderTest.php index a3095cc..da6a6eb 100644 --- a/tests/Unit/Service/Provider/GitHubProviderTest.php +++ b/tests/Unit/Service/Provider/GitHubProviderTest.php @@ -29,6 +29,7 @@ final class GitHubProviderTest extends TestCase ); } + /** @param array}> $weeks */ private function stubGraphqlResponse(array $weeks): ResponseInterface { $response = $this->createStub(ResponseInterface::class); @@ -131,4 +132,27 @@ final class GitHubProviderTest extends TestCase $this->assertSame([], $result); } + + #[Test] + public function it_narrows_the_graphql_query_window_when_since_and_until_are_given(): void + { + $since = new \DateTimeImmutable('2024-01-01'); + $until = new \DateTimeImmutable('2024-01-31'); + $capturedQuery = null; + + $client = $this->createMock(HttpClientInterface::class); + $client->method('request')->willReturnCallback( + function (string $method, string $url, array $options) use (&$capturedQuery): ResponseInterface { + $capturedQuery = $options['json']['query']; + + return $this->stubGraphqlResponse([]); + } + ); + + $provider = $this->makeProvider(client: $client); + $provider->startFetch($since, $until); + + $this->assertStringContainsString('2024-01-01T00:00:00Z', $capturedQuery); + $this->assertStringContainsString('2024-01-31T23:59:59Z', $capturedQuery); + } } diff --git a/tests/Unit/Service/Provider/GitLabProviderTest.php b/tests/Unit/Service/Provider/GitLabProviderTest.php index 34fbd9b..5673318 100644 --- a/tests/Unit/Service/Provider/GitLabProviderTest.php +++ b/tests/Unit/Service/Provider/GitLabProviderTest.php @@ -31,6 +31,7 @@ final class GitLabProviderTest extends TestCase ); } + /** @param array> $data raw JSON body, e.g. events or the user lookup */ private function stubResponse(array $data): ResponseInterface { $response = $this->createStub(ResponseInterface::class); @@ -128,4 +129,27 @@ final class GitLabProviderTest extends TestCase $this->assertSame(100, $result['2024-06-10']); $this->assertSame(1, $result['2024-06-11']); } + + #[Test] + public function it_sends_after_and_before_query_params_when_since_and_until_are_given(): void + { + $capturedQuery = null; + + $client = $this->createMock(HttpClientInterface::class); + $client->method('request')->willReturnCallback( + function (string $method, string $url, array $options = []) use (&$capturedQuery): ResponseInterface { + if (str_contains($url, '/events')) { + $capturedQuery = $options['query']; + } + + return $this->stubResponse(str_contains($url, '/events') ? [] : [['id' => 42]]); + } + ); + + $provider = $this->makeProvider(client: $client); + $provider->startFetch(new \DateTimeImmutable('2024-01-01'), new \DateTimeImmutable('2024-01-31')); + + $this->assertSame('2024-01-01', $capturedQuery['after']); + $this->assertSame('2024-01-31', $capturedQuery['before']); + } } diff --git a/tests/Unit/Service/Provider/GiteaProviderTest.php b/tests/Unit/Service/Provider/GiteaProviderTest.php index ae12471..3dd8b6d 100644 --- a/tests/Unit/Service/Provider/GiteaProviderTest.php +++ b/tests/Unit/Service/Provider/GiteaProviderTest.php @@ -30,6 +30,7 @@ final class GiteaProviderTest extends TestCase ); } + /** @param array $data */ private function stubResponse(array $data): ResponseInterface { $response = $this->createStub(ResponseInterface::class); @@ -111,4 +112,44 @@ final class GiteaProviderTest extends TestCase $this->assertSame([], $result); } + + #[Test] + public function it_filters_out_entries_before_since(): void + { + $since = new \DateTimeImmutable('2024-01-10'); + $before = $since->modify('-1 day')->getTimestamp(); + $after = $since->modify('+1 day')->getTimestamp(); + + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubResponse([ + ['timestamp' => $before, 'contributions' => 3], + ['timestamp' => $after, 'contributions' => 2], + ])); + + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch($since)); + + $this->assertArrayNotHasKey(date('Y-m-d', $before), $result); + $this->assertSame(2, $result[date('Y-m-d', $after)]); + } + + #[Test] + public function it_filters_out_entries_after_until(): void + { + $until = new \DateTimeImmutable('-10 days'); + $before = $until->modify('-1 day')->getTimestamp(); + $after = $until->modify('+1 day')->getTimestamp(); + + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubResponse([ + ['timestamp' => $before, 'contributions' => 3], + ['timestamp' => $after, 'contributions' => 2], + ])); + + $provider = $this->makeProvider(client: $client); + $result = $provider->resolveFetch($provider->startFetch(null, $until)); + + $this->assertSame(3, $result[date('Y-m-d', $before)]); + $this->assertArrayNotHasKey(date('Y-m-d', $after), $result); + } } diff --git a/tests/Unit/Service/Provider/ProbeTraitTest.php b/tests/Unit/Service/Provider/ProbeTraitTest.php index bd6ba28..d680d80 100644 --- a/tests/Unit/Service/Provider/ProbeTraitTest.php +++ b/tests/Unit/Service/Provider/ProbeTraitTest.php @@ -31,7 +31,7 @@ final class ProbeTraitTest extends TestCase public function isConfigured(): bool { return $this->configured; } public function getName(): string { return 'test'; } public function ping(): void { ($this->ping)(); } - public function startFetch(): mixed { return null; } + public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed { return null; } public function resolveFetch(mixed $handle): array { return []; } }; } -- 2.54.0 From 14ea788522936d0dc777ba8014f9d4b303d392e5 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:42:50 +0200 Subject: [PATCH 34/42] fix(renderer): coerce strtotime() result to int before formatting dates date() expects int|null, but strtotime() returns int|false; cast to make the type explicit for PHPStan level 8 (the date strings are always well-formed here, so strtotime() never actually returns false). --- src/Service/Renderer/SvgRenderer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Service/Renderer/SvgRenderer.php b/src/Service/Renderer/SvgRenderer.php index 1c16461..ab215ce 100644 --- a/src/Service/Renderer/SvgRenderer.php +++ b/src/Service/Renderer/SvgRenderer.php @@ -142,7 +142,7 @@ final class SvgRenderer if ($cell === null) { continue; } - $ts = strtotime($cell['date']); + $ts = (int) strtotime($cell['date']); $month = (int) date('n', $ts); $dom = (int) date('j', $ts); @@ -197,7 +197,7 @@ final class SvgRenderer $y = self::MARGIN_Y + $d * self::STEP; $color = $colors['levels'][$this->level($cell['count'])]; - $ts = strtotime($cell['date']); + $ts = (int) strtotime($cell['date']); $suffix = $cell['count'] !== 1 ? 's' : ''; $label = $cell['count'] > 0 ? $cell['count'] . ' contribution' . $suffix . ' on ' . date('F j, Y', $ts) -- 2.54.0 From 8e349c961bb1bcce42190115fabbea4fa734c772 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:43:01 +0200 Subject: [PATCH 35/42] fix(entity): re-index ContributionCollection constructor arguments array_map() over the variadic constructor can produce non-sequential or string keys, which doesn't match the array property type; re-index with array_values(). Also documents the iterator/count accessors. --- src/Entity/ContributionCollection.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Entity/ContributionCollection.php b/src/Entity/ContributionCollection.php index eabbec0..c1ec26e 100644 --- a/src/Entity/ContributionCollection.php +++ b/src/Entity/ContributionCollection.php @@ -14,14 +14,16 @@ final class ContributionCollection implements \IteratorAggregate, \Countable public function __construct(Contribution ...$contributions) { - $this->contributions = $contributions; + $this->contributions = array_values($contributions); } + /** @return \ArrayIterator */ public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->contributions); } + /** Number of contributions held in this collection. */ public function count(): int { return count($this->contributions); -- 2.54.0 From 9c028aaf5ef6c61bf99603829fd8920c3ab27210 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:43:31 +0200 Subject: [PATCH 36/42] docs(phpdoc): document public methods across src/ Add professional PHPDoc (summary + @param/@return) to the remaining public methods that had none or only partial coverage: GraphController's actions, ContributionStore's accessors, SvgRenderer's render() and private helpers (with concrete array-shape annotations), ProbeTrait, ProviderHealthChecker, ProviderStatus, and the Contribution entity. Also adds the matching @return shapes on SvgRendererTest's data providers. --- src/Controller/GraphController.php | 20 +++++++++++++-- src/Entity/Contribution.php | 6 +++++ src/Service/ContributionStore.php | 25 +++++++++++++++++-- src/Service/Provider/ProbeTrait.php | 4 +++ .../Provider/ProviderHealthChecker.php | 6 ++++- src/Service/Provider/ProviderStatus.php | 13 +++++++++- src/Service/Renderer/SvgRenderer.php | 19 ++++++++++++++ .../Unit/Service/Renderer/SvgRendererTest.php | 2 ++ 8 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index 6d716c9..9861041 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -34,8 +34,12 @@ final class GraphController } /** - * Query parameters: - * theme string dark|light (default: dark) + * Serves the contribution heatmap SVG, from a 1-hour cache on repeat requests. + * + * Rejects requests to disallowed hosts (ALLOWED_HOSTS env) with a 403. + * + * @param Request $request query parameter `theme` selects "dark" (default) or "light" + * @return Response image/svg+xml body, cacheable for 3600s */ #[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])] public function graph(Request $request): Response @@ -66,6 +70,12 @@ final class GraphController ]); } + /** + * Redirects "/" to "/graph.svg", forwarding any query string (e.g. ?theme=light). + * + * @param Request $request incoming request whose query string is preserved + * @return RedirectResponse 302 redirect to /graph.svg + */ #[Route('/', name: 'index', methods: ['GET'])] public function index(Request $request): RedirectResponse { @@ -75,6 +85,12 @@ final class GraphController return new RedirectResponse($url, 302); } + /** + * Reports per-provider connectivity status as JSON. + * + * @return Response 200 with {"status":"ok",...} or 503 with {"status":"degraded",...} + * when any configured provider's probe reports an error + */ #[Route('/health', name: 'health', methods: ['GET'])] public function health(): Response { diff --git a/src/Entity/Contribution.php b/src/Entity/Contribution.php index 2e05227..3472cc6 100644 --- a/src/Entity/Contribution.php +++ b/src/Entity/Contribution.php @@ -4,8 +4,14 @@ declare(strict_types=1); namespace GitContributionGraph\Entity; +/** A single provider's contribution count for one day, as stored in {@see \GitContributionGraph\Service\ContributionStore}. */ final class Contribution { + /** + * @param string $provider provider identifier, e.g. "github" + * @param int $date day of the contribution, as a unix timestamp + * @param int $count contribution count for that day + */ public function __construct( public readonly string $provider, public readonly int $date, diff --git a/src/Service/ContributionStore.php b/src/Service/ContributionStore.php index c793b19..5effcbf 100644 --- a/src/Service/ContributionStore.php +++ b/src/Service/ContributionStore.php @@ -15,6 +15,9 @@ class ContributionStore /** * Opens (creating if needed) the SQLite store at $dbPath and ensures the schema exists. + * + * @param string $dbPath filesystem path to the SQLite database file + * @param ?int $retentionDays days of history to keep; null or 0 keeps rows forever */ public function __construct( private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db", @@ -43,6 +46,10 @@ class ContributionStore /** * Inserts a contribution count, overwriting any existing count for the same provider/date. + * + * @param string $provider provider identifier, e.g. "github" + * @param int $unixtime day of the contribution, as a unix timestamp + * @param int $count contribution count for that day */ public function add(string $provider, int $unixtime, int $count): void { @@ -56,6 +63,9 @@ class ContributionStore /** * Deletes the contribution row for the given provider/date, if any. + * + * @param string $provider provider identifier, e.g. "github" + * @param int $unixtime day to delete, as a unix timestamp */ public function remove(string $provider, int $unixtime): void { @@ -69,7 +79,8 @@ class ContributionStore /** * Upserts a batch of date => count pairs for a provider via repeated add() calls. * - * @param array $dateCounts unix timestamp => count + * @param string $provider provider identifier, e.g. "github" + * @param array $dateCounts unix timestamp => count */ public function merge(string $provider, array $dateCounts): void { @@ -80,6 +91,9 @@ class ContributionStore /** * Returns the most recent stored unix timestamp for a provider, or null if it has no rows. + * + * @param string $provider provider identifier, e.g. "github" + * @return ?int unix timestamp of the latest stored day, or null if none stored yet */ public function latestDate(string $provider): ?int { @@ -92,6 +106,10 @@ class ContributionStore /** * Returns all stored contributions for a provider, optionally limited to the last $sinceDays days. + * + * @param string $provider provider identifier, e.g. "github" + * @param ?int $sinceDays if set, only rows from the last N days are returned + * @return ContributionCollection */ public function all(string $provider, ?int $sinceDays = null): ContributionCollection { @@ -106,9 +124,12 @@ class ContributionStore $stmt = $this->pdo->prepare($sql); $stmt->execute($params); + /** @var array $rows */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $contributions = array_map( static fn(array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']), - $stmt->fetchAll(PDO::FETCH_ASSOC), + $rows, ); return new ContributionCollection(...$contributions); diff --git a/src/Service/Provider/ProbeTrait.php b/src/Service/Provider/ProbeTrait.php index df3ae4e..194261a 100644 --- a/src/Service/Provider/ProbeTrait.php +++ b/src/Service/Provider/ProbeTrait.php @@ -9,6 +9,10 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; trait ProbeTrait { + /** + * Reports NotConfigured if credentials are missing, otherwise pings the provider + * and reports Ok or Error (with a classified error code and message). + */ public function probe(): ProviderStatus { if (!$this->isConfigured()) { diff --git a/src/Service/Provider/ProviderHealthChecker.php b/src/Service/Provider/ProviderHealthChecker.php index c4a4f85..c81838f 100644 --- a/src/Service/Provider/ProviderHealthChecker.php +++ b/src/Service/Provider/ProviderHealthChecker.php @@ -8,13 +8,17 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; final class ProviderHealthChecker { + /** @param iterable $providers */ public function __construct( #[AutowireIterator('app.provider')] private readonly iterable $providers, ) {} /** - * @return array{status: string, providers: array>} + * Probes every configured provider and aggregates the results for the /health endpoint. + * + * @return array{status: string, providers: array>} 'status' is + * "degraded" if any provider's probe() reported an error, otherwise "ok" */ public function check(): array { diff --git a/src/Service/Provider/ProviderStatus.php b/src/Service/Provider/ProviderStatus.php index 7ae1517..bfcd0c4 100644 --- a/src/Service/Provider/ProviderStatus.php +++ b/src/Service/Provider/ProviderStatus.php @@ -4,8 +4,15 @@ declare(strict_types=1); namespace GitContributionGraph\Service\Provider; +/** Result of probing a single provider, as reported by the /health endpoint. */ final class ProviderStatus { + /** + * @param string $name provider identifier, e.g. "github" + * @param ProviderStatusType $status overall outcome of the probe + * @param ?ProviderErrorCode $error classified error code, set only when $status is Error + * @param ?string $message human-readable error detail, set only when $status is Error + */ public function __construct( public readonly string $name, public readonly ProviderStatusType $status, @@ -13,7 +20,11 @@ final class ProviderStatus public readonly ?string $message = null, ) {} - /** @return array */ + /** + * Converts to the array shape used in the /health JSON response, omitting error/message when unset. + * + * @return array + */ public function toArray(): array { $data = ['status' => $this->status->value]; diff --git a/src/Service/Renderer/SvgRenderer.php b/src/Service/Renderer/SvgRenderer.php index ab215ce..0c48db2 100644 --- a/src/Service/Renderer/SvgRenderer.php +++ b/src/Service/Renderer/SvgRenderer.php @@ -36,6 +36,12 @@ final class SvgRenderer private const MARGIN_Y = 20; // top margin for month labels private const PADDING = 10; // outer padding + /** + * Builds the full self-contained SVG heatmap for a contribution map. + * + * @param array $contributions date (Y-m-d) => contribution count + * @param string $theme "dark" or "light"; unknown values fall back to "dark" + */ public function render(array $contributions, string $theme = 'dark'): string { $colors = self::THEMES[$theme] ?? self::THEMES['dark']; @@ -91,6 +97,9 @@ final class SvgRenderer /** * Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int] * or null if the date is in the future. + * + * @param array $contributions date (Y-m-d) => contribution count + * @return array> */ private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array { @@ -125,6 +134,10 @@ final class SvgRenderer }; } + /** + * @param array $contributions date (Y-m-d) => contribution count + * @return array{total: int} + */ private function computeStats(array $contributions): array { return ['total' => array_sum($contributions)]; @@ -132,6 +145,7 @@ final class SvgRenderer // ------------------------------------------------------------------------- + /** @param array> $grid */ private function renderMonthLabels(array $grid, string $textColor): string { $out = ''; @@ -183,6 +197,10 @@ final class SvgRenderer return $out; } + /** + * @param array> $grid + * @param array{bg: string, text: string, levels: array} $colors + */ private function renderCells(array $grid, array $colors): string { $out = ''; @@ -218,6 +236,7 @@ final class SvgRenderer return $out; } + /** @param array{bg: string, text: string, levels: array} $colors */ private function renderLegend(int $totalH, array $colors): string { $y = $totalH - 14; diff --git a/tests/Unit/Service/Renderer/SvgRendererTest.php b/tests/Unit/Service/Renderer/SvgRendererTest.php index 705bd79..ab8f6cc 100644 --- a/tests/Unit/Service/Renderer/SvgRendererTest.php +++ b/tests/Unit/Service/Renderer/SvgRendererTest.php @@ -54,6 +54,7 @@ final class SvgRendererTest extends TestCase $this->assertStringContainsString($expectedColor, $svg); } + /** @return iterable */ public static function theme_background_provider(): iterable { yield 'dark theme' => ['dark', '#0d1117']; @@ -97,6 +98,7 @@ final class SvgRendererTest extends TestCase $this->assertStringContainsString('>' . $label . '<', $svg); } + /** @return iterable */ public static function day_of_week_label_provider(): iterable { yield 'Monday' => ['Mon']; -- 2.54.0 From de831beaaa0a49e8a9341b2463e3a3f33155e171 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:43:52 +0200 Subject: [PATCH 37/42] chore(phpstan): raise analyser memory limit and record resolved findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --memory-limit=512M to the phpstan composer script — the default 128M crashed the level-8 run under parallel workers. Update PHP-Stan-Errors.md to note all 22 catalogued findings are now resolved. --- PHP-Stan-Errors.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++ composer.json | 3 +++ 2 files changed, 54 insertions(+) create mode 100644 PHP-Stan-Errors.md diff --git a/PHP-Stan-Errors.md b/PHP-Stan-Errors.md new file mode 100644 index 0000000..9e89c09 --- /dev/null +++ b/PHP-Stan-Errors.md @@ -0,0 +1,51 @@ +# PHPStan level 8 — first run + +**Resolved.** All 22 errors below were closed by adding the missing +array-shape docblocks (plus two `(int) strtotime()` casts and an +`array_values()` re-index in `ContributionCollection`). `composer phpstan` +now reports zero errors. Kept here as a record of what level 8 flagged on +first run. + +`composer phpstan` (level 8, `paths: [src, tests]`) reports 22 errors, all +`missingType.iterableValue` / `missingType.generics` — none are correctness +bugs, all are missing `@param`/`@return` array-shape docblocks on iterable +parameters. + +Run: `docker compose exec graph composer phpstan` (needs `--memory-limit=512M` +if run directly via `vendor/bin/phpstan analyse`, the default 128M crashes on +this project's parallel workers). + +## `missingType.iterableValue` — `iterable $providers` constructor params + +- `src/Command/RefetchContributionsCommand.php:29` +- `src/Service/ContributionAggregator.php:16` +- `src/Service/Provider/ProviderHealthChecker.php:11` + +Fix: add `@param iterable $providers` above each +constructor. + +## `missingType.iterableValue` — `SvgRenderer.php` (8 occurrences) + +Every `array` parameter/return on `render()`, `buildGrid()`, +`computeStats()`, `renderMonthLabels()`, `renderCells()`, `renderLegend()` is +untyped. Fix: add `@param array` / `@return array<...>` shapes +matching each method's actual contents (contribution date=>count maps, grid +cell arrays, color arrays). + +## `missingType.generics` / `assign.propertyType` — `ContributionCollection.php` + +- Line 17: `$contributions` is typed `array` but + `array_map` over `Contribution ...$contributions` in the constructor can + produce non-sequential/string keys. Fix: re-index with `array_values()` + before assigning, or relax the property type to `array`. +- Line 20: `getIterator(): \ArrayIterator` needs generic types: + `@return \ArrayIterator`. + +## `missingType.iterableValue` — test doubles/providers (8 occurrences) + +Stub/fixture helper methods with untyped `array` params in +`RefetchContributionsCommandTest`, `ContributionAggregatorTest`, +`GitHubProviderTest`, `GitLabProviderTest`, `GiteaProviderTest`, and the two +PHPUnit data providers in `SvgRendererTest`. Fix: add +`@param array $fresh` (or the relevant shape) / `@return +iterable` on the data providers. diff --git a/composer.json b/composer.json index ded591b..1448704 100644 --- a/composer.json +++ b/composer.json @@ -41,5 +41,8 @@ "allow-contrib": false, "require": "7.4.*" } + }, + "scripts": { + "phpstan": "phpstan analyse --memory-limit=512M" } } -- 2.54.0 From cf7bd8d70c74f26caf21f9280818e71d7c245c3f Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:43:59 +0200 Subject: [PATCH 38/42] docs(readme): correct outdated deploy docs and docker-compose examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README had drifted from the codebase: wrong test path, a stale /health sample response, a docker-compose volume mount pointing at a path the image never writes to (with no SQLite data volume at all), a missing CONTRIBUTIONS_RETENTION_DAYS example, and an architecture diagram predating the SQLite store/refetch command. Also fixes docker-compose.prod.yml, which was missing the data volume and CONTRIBUTIONS_RETENTION_DAYS env var present in docker-compose.yml — without it, a prod deployment on this file loses contribution history on every container recreate. --- README.md | 80 ++++++++++++++++++++++++++++++++++------- docker-compose.prod.yml | 3 ++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index af3053f..026ff10 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,14 @@ services: env_file: - .env.local volumes: - - cache:/var/www/html/var/cache + - cache:/app/var/cache/prod/pools + - logs:/app/var/log + - data:/app/var/data volumes: cache: + logs: + data: ``` The image is published to the Gitea container registry. Pull it manually with: @@ -77,6 +81,9 @@ GITEA_URL=https://git.example.com # Optional: restrict to specific hostnames (comma-separated), leave empty to allow all ALLOWED_HOSTS= + +# Optional: days of contribution history to keep in the SQLite store, empty/0 = keep forever +CONTRIBUTIONS_RETENTION_DAYS= ``` Only configure the platforms you use — unused ones are silently skipped. @@ -87,15 +94,26 @@ Only configure the platforms you use — unused ones are silently skipped. docker compose up -d ``` -The service listens on **port 8080** by default. Put Traefik or nginx in front of it for HTTPS. +The service listens on **port 8080** by default (served by FrankenPHP/Caddy, baked into the image — no separate PHP-FPM/nginx needed). Put Traefik or nginx in front of it only if you need HTTPS termination. ### 4. Verify ```bash curl http://localhost:8080/health -# {"status":"ok"} ``` +```json +{ + "status": "ok", + "providers": { + "github": { "status": "ok" }, + "gitlab": { "status": "ok" } + } +} +``` + +Returns HTTP 503 with `"status": "degraded"` if any configured provider's probe fails (its entry then includes `error` and `message`). + --- ## API @@ -201,28 +219,43 @@ vendor/bin/phpunit vendor/bin/phpunit --testdox # Single file -vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php +vendor/bin/phpunit tests/Unit/Service/Renderer/SvgRendererTest.php # Filter by name vendor/bin/phpunit --filter it_renders ``` +### Static analysis + +```bash +composer phpstan +``` + +Runs PHPStan at level 8 over `src/` and `tests/` (see `phpstan.neon`). First +run's findings are catalogued in [PHP-Stan-Errors.md](PHP-Stan-Errors.md). + --- ## Architecture +Two-tier cache: a 1h filesystem SVG cache in front of a SQLite raw-data store, in front of the provider APIs. + ``` GET /graph.svg?theme=dark|light └─ GraphController ├─ host check (ALLOWED_HOSTS env, optional) ├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}") - │ └─ on miss: - │ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar) - │ ├─ GitLabProvider → GitLab REST API (/users/:id/events, paginated) - │ └─ GiteaProvider → Gitea REST API (/users/:user/heatmap) - │ each returns array (Y-m-d => count) - │ failures are caught and logged; remaining providers still render - │ └─ merge by date (sum counts across providers) + │ └─ 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); providers fetch concurrently + │ │ (start/resolve split); 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 ``` @@ -231,7 +264,30 @@ GET /graph.svg?theme=dark|light **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 → level 0, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained. -**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. +**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), keyed by `(provider, date)`. Bounds every re-fetch to a trailing window off the last stored date per provider, instead of re-pulling all 365 days on every cache miss. `CONTRIBUTIONS_RETENTION_DAYS` controls how far back rows are kept (empty/0 = forever). + +**Health check:** `GET /health` probes each configured provider's credentials/reachability (without fetching contribution data) and reports `ok`/`degraded`. + +--- + +## CLI + +`graph:contributions:refetch` is a manual escape hatch that bypasses the incremental trailing-window fetch and re-pulls a full or explicit date range — useful for backfilling history or recovering from a gap. + +```bash +docker compose exec graph bin/console graph:contributions:refetch --all +``` + +| Option | Default | Description | +| ------------ | -------------------- | --------------------------------------------------------- | +| `--provider` | all configured | Comma-separated provider names to refetch, e.g. `github,gitlab` | +| `--from` | 365 days ago | Start date (`YYYY-MM-DD`) | +| `--to` | today | End date (`YYYY-MM-DD`) | +| `--all` | off | Shorthand for `--from=2005-01-01` | + +Large ranges are split into ≤365-day chunks and merged into the store as each completes. --- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cccefcb..ce08665 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,9 +18,11 @@ services: GITEA_USER: "${GITEA_USER:-}" GITEA_TOKEN: "${GITEA_TOKEN:-}" GITEA_URL: "${GITEA_URL:-}" + CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}" volumes: - cache:/app/var/cache/prod/pools - logs:/app/var/log + - data:/app/var/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s @@ -30,3 +32,4 @@ services: volumes: cache: logs: + data: -- 2.54.0 From 20e943a2b54668b44408693d64ec909bf72f55f0 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:44:04 +0200 Subject: [PATCH 39/42] docs(claude): document SQLite store, refetch command, and phpstan script Update the architecture diagram and env var table to reflect the ContributionStore tier and incremental fetch, document the graph:contributions:refetch command, and note composer phpstan next to the existing test commands. --- CLAUDE.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e7769a..d9384df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,9 @@ 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): @@ -190,20 +193,24 @@ There is no `composer.lock` in the repo. If you add or change dependencies, run ## Architecture -Single-controller Symfony app with no database. The request flow: +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: - │ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar query) - │ ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page) - │ └─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap - │ each returns array (Y-m-d => count) - │ failures are caught and logged; remaining providers still render - │ └─ merge by date (sum counts across providers) + │ └─ 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 ``` @@ -212,7 +219,11 @@ GET /graph.svg?theme=dark|light **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. -**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. +**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 @@ -224,5 +235,6 @@ GET /graph.svg?theme=dark|light | `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. -- 2.54.0 From be6146d5adf2bed38351cfd6678e8faf4f2dfd15 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:44:09 +0200 Subject: [PATCH 40/42] chore(todo): mark completed roadmap items Check off the incremental-fetch/store wiring, refetch command, phpstan setup, and CLAUDE.md update items now that all are done. --- TODO.md | 61 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/TODO.md b/TODO.md index 10121f4..87c6865 100644 --- a/TODO.md +++ b/TODO.md @@ -118,27 +118,27 @@ window (step 5's 3-day trailing overlap) is what actually fixes it — a PHP `max_execution_time` fatal can't be caught by `try/catch` at all, so "catch it better" was never on the table. -- [ ] `ProviderInterface::startFetch()` → `startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed` +- [x] `ProviderInterface::startFetch()` → `startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed` (interface already split into `startFetch`/`resolveFetch` for concurrency — this doc previously said `fetch()`, which predates that split). -- [ ] `GitHubProvider::startFetch()` — already builds explicit `from`/`to` +- [x] `GitHubProvider::startFetch()` — already builds explicit `from`/`to` GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded `-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`. -- [ ] `GitLabProvider::startFetch()` — add a `before` query param alongside +- [x] `GitLabProvider::startFetch()` — add a `before` query param alongside the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`; `$since` already flows into `after` (this is what actually shrinks the pagination loop). -- [ ] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query +- [x] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query params (always returns full history); add an `$until` upper-bound filter alongside the existing `$cutoff` lower bound. -- [ ] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`, +- [x] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`, `GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until` narrows the request window/query params. ## 5. Wire `ContributionStore` into `ContributionAggregator` -- [ ] Fix `ContributionStore` wiring in `config/services.yaml` first — +- [x] Fix `ContributionStore` wiring in `config/services.yaml` first — it's currently dead code (nothing calls it). The constructor default `$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is a plain PHP default, not a resolved container parameter; bind it @@ -152,36 +152,36 @@ PHP `max_execution_time` fatal can't be caught by `try/catch` at all, so Add `env(CONTRIBUTIONS_RETENTION_DAYS): ''` to `parameters:` (empty → casts to `0` → `prune()`'s existing `retentionDays === 0` check already treats that as "keep forever"). Document the var in `.env`. -- [ ] Inject `ContributionStore` into `ContributionAggregator`. -- [ ] Per configured provider: `$latest = $store->latestDate($name)` → +- [x] Inject `ContributionStore` into `ContributionAggregator`. +- [x] Per configured provider: `$latest = $store->latestDate($name)` → `$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null` (3-day overlap for late corrections — old stored days are immutable and never re-fetched, only this trailing window + anything new hits the network), else `null` (first run, provider's own default lookback). `$until = null` (always "up to now" on the normal request path). -- [ ] `startFetch($since, $until)` / `resolveFetch()` as today, +- [x] `startFetch($since, $until)` / `resolveFetch()` as today, `$store->merge($name, $fresh)` on success, then read back `$store->all($name, sinceDays: 371)` for the render window (53 weeks × 7 days) — a `ContributionCollection` — and merge its contributions into the returned array (the store becomes the source of truth for what gets rendered, not the fresh fetch alone). -- [ ] Call `$store->prune()` once per `aggregate()` call, after all +- [x] Call `$store->prune()` once per `aggregate()` call, after all providers have merged. -- [ ] Keep the existing try/catch-and-log-per-provider behavior — a +- [x] Keep the existing try/catch-and-log-per-provider behavior — a provider failure leaves its DB history stale, doesn't break the render. -- [ ] Update `ContributionAggregatorTest.php` with store-interaction +- [x] Update `ContributionAggregatorTest.php` with store-interaction (`latestDate` consulted, `merge` called, `all()` feeds the result, `prune()` runs once) and a case confirming a provider failure leaves other providers' stored data intact. ## 6. Docker / env -- [ ] `docker-compose.yml` — add a `data` named volume mounted at +- [x] `docker-compose.yml` — add a `data` named volume mounted at `/app/var/data` (same pattern as `cache`/`logs`), and pass through `CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`. -- [ ] `Dockerfile` — add `var/data` to the `mkdir -p` in the `final` stage +- [x] `Dockerfile` — add `var/data` to the `mkdir -p` in the `final` stage alongside `var/cache/prod/pools var/log`, owned by `app`. -- [ ] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default), +- [x] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default), same style as the existing `ALLOWED_HOSTS` comment. ## 9. `graph:contributions:refetch` console command @@ -190,34 +190,34 @@ Manual escape hatch for forcing a full or ranged re-fetch (e.g. after adding a new host, or if the store needs rebuilding) — bypasses step 5's incremental `latestDate()` window entirely for the providers/range given. -- [ ] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` + +- [x] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` + constructor-promoted readonly properties (matches this codebase's attribute-first style), using `SymfonyStyle` ([console styling guide](https://symfony.com/doc/current/console/style.html)). Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`, same as `ContributionAggregator`) and `ContributionStore`. -- [ ] Options: `--provider=github,gitlab` (repeatable/comma-split, +- [x] Options: `--provider=github,gitlab` (repeatable/comma-split, restricts to named provider(s), default = all configured; unknown name → `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default today − 365 days), `--to=YYYY-MM-DD` (default today), `--all` (shorthand for `--from` far enough back — e.g. 2005-01-01 — to mean "existing full history"; combinable with `--provider`). -- [ ] **Batch by range**: split `[from, to]` into ≤365-day chunks +- [x] **Batch by range**: split `[from, to]` into ≤365-day chunks (GitHub's GraphQL `contributionsCollection` rejects windows over a year; chunking also caps GitLab's pagination per call, so a big `--all` re-fetch can't reintroduce the original runaway-pagination timeout). Iterate chunks oldest-first. -- [ ] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)` +- [x] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)` / `resolveFetch()`, `$store->merge($name, $result)` immediately (don't accumulate all chunks in memory), `ProgressBar` across chunks. -- [ ] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`, +- [x] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`, continue with remaining chunks/providers (same graceful-degradation philosophy as `ContributionAggregator`). -- [ ] Finish with `$io->table(...)` summary (provider, days written, +- [x] Finish with `$io->table(...)` summary (provider, days written, chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit `Command::SUCCESS` if at least one provider fully succeeded, else `Command::FAILURE`. -- [ ] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php` +- [x] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php` using `CommandTester` with fake `ProviderInterface` stubs and a `:memory:` `ContributionStore` — assert chunking count for a >365-day range, store ends up populated, unknown `--provider` name fails @@ -228,20 +228,21 @@ incremental `latestDate()` window entirely for the providers/range given. - [x] `composer require --dev phpstan/phpstan` (plain PHPStan — no Symfony extension needed for this app's size). - [x] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`. -- [ ] Add composer script `"phpstan": "phpstan analyse"`. -- [ ] Update README.md docs with new command and php stan static lintin. -- [ ] Document in PHP-Stan-Errors.md whatever level-8 flags on first run. +- [x] Add composer script `"phpstan": "phpstan analyse"`. +- [x] Update README.md docs with new command and php stan static lintin. +- [x] Document in PHP-Stan-Errors.md whatever level-8 flags on first run. ## 11. `CLAUDE.md` update -- [ ] Architecture section: add the `ContributionStore` (SQLite) tier +- [x] Architecture section: add the `ContributionStore` (SQLite) tier between providers and the renderer; note the two-tier cache (1h SVG cache → SQLite raw-data store → provider APIs). -- [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`. -- [ ] Document `app:contributions:refetch` (options, batching behavior). -- [ ] Development section: add `composer phpstan` next to the existing +- [x] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`. +- [x] Document `app:contributions:refetch` (options, batching behavior). +- [x] Development section: add `composer phpstan` next to the existing `vendor/bin/phpunit` commands. -- [ ] Remove any remaining mentions of the two deleted bundles. +- [x] Remove any remaining mentions of the two deleted bundles. (none found — + already clean) ## Verification -- 2.54.0 From ac515400e0b0d220cebb2f66f5d2fffffa0e93d6 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:44:32 +0200 Subject: [PATCH 41/42] chore(phpstan): remove PHP-Stan-Errors.md All findings it catalogued are resolved; drop the file rather than keep a stale error log around. --- PHP-Stan-Errors.md | 51 ---------------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 PHP-Stan-Errors.md diff --git a/PHP-Stan-Errors.md b/PHP-Stan-Errors.md deleted file mode 100644 index 9e89c09..0000000 --- a/PHP-Stan-Errors.md +++ /dev/null @@ -1,51 +0,0 @@ -# PHPStan level 8 — first run - -**Resolved.** All 22 errors below were closed by adding the missing -array-shape docblocks (plus two `(int) strtotime()` casts and an -`array_values()` re-index in `ContributionCollection`). `composer phpstan` -now reports zero errors. Kept here as a record of what level 8 flagged on -first run. - -`composer phpstan` (level 8, `paths: [src, tests]`) reports 22 errors, all -`missingType.iterableValue` / `missingType.generics` — none are correctness -bugs, all are missing `@param`/`@return` array-shape docblocks on iterable -parameters. - -Run: `docker compose exec graph composer phpstan` (needs `--memory-limit=512M` -if run directly via `vendor/bin/phpstan analyse`, the default 128M crashes on -this project's parallel workers). - -## `missingType.iterableValue` — `iterable $providers` constructor params - -- `src/Command/RefetchContributionsCommand.php:29` -- `src/Service/ContributionAggregator.php:16` -- `src/Service/Provider/ProviderHealthChecker.php:11` - -Fix: add `@param iterable $providers` above each -constructor. - -## `missingType.iterableValue` — `SvgRenderer.php` (8 occurrences) - -Every `array` parameter/return on `render()`, `buildGrid()`, -`computeStats()`, `renderMonthLabels()`, `renderCells()`, `renderLegend()` is -untyped. Fix: add `@param array` / `@return array<...>` shapes -matching each method's actual contents (contribution date=>count maps, grid -cell arrays, color arrays). - -## `missingType.generics` / `assign.propertyType` — `ContributionCollection.php` - -- Line 17: `$contributions` is typed `array` but - `array_map` over `Contribution ...$contributions` in the constructor can - produce non-sequential/string keys. Fix: re-index with `array_values()` - before assigning, or relax the property type to `array`. -- Line 20: `getIterator(): \ArrayIterator` needs generic types: - `@return \ArrayIterator`. - -## `missingType.iterableValue` — test doubles/providers (8 occurrences) - -Stub/fixture helper methods with untyped `array` params in -`RefetchContributionsCommandTest`, `ContributionAggregatorTest`, -`GitHubProviderTest`, `GitLabProviderTest`, `GiteaProviderTest`, and the two -PHPUnit data providers in `SvgRendererTest`. Fix: add -`@param array $fresh` (or the relevant shape) / `@return -iterable` on the data providers. -- 2.54.0 From 4825c45a7c8fc48cdac4c83c8da8455bc5aa9412 Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 12 Jul 2026 23:51:19 +0200 Subject: [PATCH 42/42] chore(update): changelog 0.2.0 --- CHANGELOG.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6665ec2..37c9c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-07-12 + +### Added + +- **Contribution history store (SQLite)** — `ContributionStore` persists each provider's daily counts in `var/data/contributions.db`; `ContributionAggregator` now fetches only the window since each provider's last stored date (plus a 3-day trailing overlap for late corrections), merges the result into the store, and prunes rows older than `CONTRIBUTIONS_RETENTION_DAYS` +- **`graph:contributions:refetch` console command** — forces a full or explicit-range re-fetch of contribution history, chunked into ≤365-day windows, for one, several, or all configured providers +- **Concurrent, non-blocking provider fetches** — `ProviderInterface::fetch()` split into `startFetch()`/`resolveFetch()` so GitHub, GitLab, and Gitea requests are in flight simultaneously instead of one after another +- **Bounded fetch windows** — `startFetch()`/`resolveFetch()` accept optional `$since`/`$until` bounds, used by both the incremental aggregator and the refetch command +- **PHPStan static analysis** — level 8 over `src/` and `tests/`, run via `composer phpstan` +- `Contribution` and `ContributionCollection` typed entities +- `CONTRIBUTIONS_RETENTION_DAYS` environment variable + +### Changed + +- **Namespace renamed from `App` to `GitContributionGraph`** across the codebase, Composer autoloading, and routing config +- Removed the Guzzle and GraphQL client bundles — GitHub requests now go through Symfony's `HttpClientInterface` directly, the same as GitLab and Gitea +- HTTP client requests are capped (10s connect/first-byte timeout, 15s max duration) so a slow provider can no longer stall the render path +- `docker-compose.yml` and `docker-compose.prod.yml` mount a `data` volume for the SQLite store and pass through `CONTRIBUTIONS_RETENTION_DAYS` +- Dev Docker Compose override targets the `dev` build stage explicitly and mounts `vendor` read-write instead of as an anonymous volume + +### Fixed + +- `ContributionCollection`'s variadic constructor now re-indexes its arguments with `array_values()`, avoiding non-sequential keys that didn't match its declared property type +- `SvgRenderer` coerces `strtotime()` results to `int` before formatting dates +- `docker-compose.prod.yml` was missing the `data` volume and `CONTRIBUTIONS_RETENTION_DAYS` env var present in `docker-compose.yml`, so contribution history would not survive a container recreate on that deployment path +- `composer phpstan` no longer crashes with an out-of-memory error under the default 128M limit + +### Documentation + +- README and `CLAUDE.md` updated to describe the SQLite store, incremental fetch, refetch command, and PHPStan setup; corrected several stale examples (test path, `/health` sample response, Docker volume mounts) +- Added PHPDoc to the remaining public methods across `src/` + +### Tests + +- Added tests for `ContributionStore`, `Contribution`, `ContributionCollection`, and `RefetchContributionsCommand` +- Extended provider and aggregator tests to cover `$since`/`$until` windowing, concurrent-fetch failure modes, and store interactions + ## [0.1.0] - 2026-05-30 ### Added @@ -42,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Docker multi-stage build (`base → deps → build → final`) - Basic `/health` endpoint -[Unreleased]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.1.0...HEAD +[Unreleased]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.2.0...HEAD +[0.2.0]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.1.0...0.2.0 [0.1.0]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.0.1...0.1.0 [0.0.1]: https://github.com/ArthurErlich/git-contribution-graph/releases/tag/0.0.1 -- 2.54.0