15 KiB
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 atGitHubProvider.php:76-77).- Replace
$graphqlClient->buildQuery(...)->getGraphQLQuery()with a plainsprintf/heredoc query string. Delete the two packages fromcomposer.json,config/bundles.php, and deleteconfig/packages/eight_points_guzzle.yaml+config/packages/idci_graphql_client.yaml. Runcomposer updateand commit the regeneratedcomposer.lock.
- Replace
-
Dedupe
baseUrlnormalization.GitLabProvider.php(lines 41, 53) andGiteaProvider.php(lines 42, 54) each callrtrim($this->baseUrl..., '/')twice — once inping(), once infetch(). Compute it once as aprivate readonly string $baseUrlin the constructor instead.
2. Fix stale config/services.yaml (found during exploration, blocks step 1 & 4)
services.yamlstill binds$username/$token/$baseUrlto the pre-refactor FQCNs (App\Service\GitHubProvideretc.) and_instanceof: App\Service\ProviderInterface, left over from theService/→Provider/+Renderer/namespace reorg. Update all of these toApp\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)
Docs: PDO · PDO_SQLITE driver · PDO::prepare / prepared statements · SQLite
INSERT ... ON CONFLICTupsert · SQLiteSTRICTtables · SQLiteWITHOUT ROWIDtables · SQLite datatypes (whydateisINTEGER/unixtime, notTEXT)
- 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:dateis stored as a Unix timestamp (INTEGER), notTEXT— matches the current implementation:CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, date INTEGER NOT NULL, count INTEGER NOT NULL CHECK (count >= 0), PRIMARY KEY (provider, date) ) WITHOUT ROWID, STRICT; add(string $provider, int $unixtime, int $count): void— plain insert (done, needs test coverage — see below).- Fix
add(): currently a plainINSERT, so re-adding an existing(provider, date)throws a unique-constraint violation instead of upserting. Switch toINSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count(see SQLite upsert doc above), or keepadd()insert-only and add a separatemerge()for the upsert case used by step 5. remove(string $provider, int $unixtime): void— started, has a bug:DELETE contributions WHERE ...is invalid SQL, missing theFROMkeyword (must beDELETE FROM contributions WHERE ...); also drop theLIKEonprovider(exact match, use=) since it's an unindexed wildcard scan for what should be an exact key lookup.Contributionentity. 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, includingadd()/all()),count(int). GivesContributionStore::all()and the aggregator something typed to pass around instead of raw arrays/tuples.ContributionCollection. Typed collection (src/Entity/ContributionCollection.php) wrappingarray<Contribution>— implementIteratorAggregate+Countableat minimum (IteratorAggregate,Countable) so it can beforeach'd andcount()'d like a normal array. This is whatContributionStore::all()should return instead of a baredate => countarray.latestDate(string $provider): ?int—SELECT MAX(date) WHERE provider = ?(returns a unix timestamp, not a string, per the schema above).merge(string $provider, array $dateCounts): void— upsert viaINSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count;$dateCountskeyed by unix timestamp.all(string $provider, ?int $sinceDays = null): ContributionCollection— fetch all rows for a provider, or since a given range.nullreturns full history (no arbitrary cutoff), a value filters to rows wheredate >= (now - sinceDays * 86400). Bug: current stub inContributionStore.php:58has invalid PHP ($provider == nullinstead of?string $provider = null— this won't parse) and returnsvoidinstead ofContributionCollection; fix the signature when implementing.prune(): void—DELETE FROM contributions WHERE date < ?using the configured retention window (a unix timestamp cutoff); no-ops if retention is unset/0.- Constructor takes
?int $retentionDaysbound from new env varCONTRIBUTIONS_RETENTION_DAYS(empty/default = keep forever). (constructor param wired; the env binding itself is step 6's job.) - Tests:
tests/Unit/Service/ContributionStoreTest.php— store & retrieve, upsert overwrites existing date,sinceDaysfiltering,prune()no-ops when retention unset,prune()deletes rows older than the window when set. - Tests:
tests/Unit/Entity/ContributionTest.phpandContributionCollectionTest.php— construction, iteration,count().
4. Wire $since/$until through providers
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 intostartFetch/resolveFetchfor concurrency — this doc previously saidfetch(), which predates that split).GitHubProvider::startFetch()— already builds explicitfrom/toGraphQL args (GitHubProvider.php:51-58); swap the hardcoded-365 days/nowfor$since ?? -365 days/$until ?? now.GitLabProvider::startFetch()— add abeforequery param alongside the existingafter(GitLabProvider.php:77-84), fed by$until;$sincealready flows intoafter(this is what actually shrinks the pagination loop).GiteaProvider::resolveFetch()— heatmap endpoint has no query params (always returns full history); add an$untilupper-bound filter alongside the existing$cutofflower bound.- Update
GitHubProviderTest.php,GitLabProviderTest.php,GiteaProviderTest.php— add cases asserting a passed$since/$untilnarrows the request window/query params.
5. Wire ContributionStore into ContributionAggregator
- Fix
ContributionStorewiring inconfig/services.yamlfirst — it's currently dead code (nothing calls it). The constructor default$dbPathstring (%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):AddApp\Service\ContributionStore: arguments: $dbPath: "%kernel.project_dir%/var/data/contributions.db" $retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%"env(CONTRIBUTIONS_RETENTION_DAYS): ''toparameters:(empty → casts to0→prune()'s existingretentionDays === 0check already treats that as "keep forever"). Document the var in.env. - Inject
ContributionStoreintoContributionAggregator. - 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), elsenull(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) — aContributionCollection— 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 peraggregate()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.phpwith store-interaction (latestDateconsulted,mergecalled,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 adatanamed volume mounted at/app/var/data(same pattern ascache/logs), and pass throughCONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}".Dockerfile— addvar/datato themkdir -pin thefinalstage alongsidevar/cache/prod/pools var/log, owned byapp..env— documentCONTRIBUTIONS_RETENTION_DAYS(empty by default), same style as the existingALLOWED_HOSTScomment.
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
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), usingSymfonyStyle(console styling guide). Injectiterable $providers(#[AutowireIterator('app.provider')], same asContributionAggregator) andContributionStore.- 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--fromfar 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 GraphQLcontributionsCollectionrejects windows over a year; chunking also caps GitLab's pagination per call, so a big--allre-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),ProgressBaracross chunks. - Catch per-provider/per-chunk
\Throwable→$io->warning(...), continue with remaining chunks/providers (same graceful-degradation philosophy asContributionAggregator). - Finish with
$io->table(...)summary (provider, days written, chunks fetched, any errors) and$io->success()/$io->error(); exitCommand::SUCCESSif at least one provider fully succeeded, elseCommand::FAILURE. - Tests:
tests/Unit/Command/RefetchContributionsCommandTest.phpusingCommandTesterwith fakeProviderInterfacestubs and a:memory:ContributionStore— assert chunking count for a >365-day range, store ends up populated, unknown--providername 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. - 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.
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 phpstannext to the existingvendor/bin/phpunitcommands. - Remove any remaining mentions of the two deleted bundles.
Verification
vendor/bin/phpunit --testdoxgreen after every step.composer phpstanclean at level 8.composer installsucceeds 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=darktwice; checkdocker compose logs -f graphshows a narrowersincewindow 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/healthstill reports all configured providers healthy.docker compose exec graph bin/console app:contributions:refetch --allpopulatesvar/data/contributions.dbfrom 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-01exercises multi-chunk batching.- Confirm the original crash scenario is gone: an account with heavy
GitLab history no longer triggers
MaxExecutionTimeErroron a cold cache (bounded by the 3-day trailing window, not full history).