Save to database #13

Merged
haylan merged 42 commits from save-to-database into main 2026-07-12 21:51:57 +00:00
Showing only changes of commit 0d7cf68771 - Show all commits
+135 -135
View File
@@ -7,33 +7,32 @@ Order matters — later steps assume earlier ones are done.
## 1. Audit cleanup ## 1. Audit cleanup
- [x] **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 They exist only to build one near-static GitHub GraphQL query string
(`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already
bypassed (comment at `GitHubProvider.php:76-77`). bypassed (comment at `GitHubProvider.php:76-77`).
- [x] Replace - [x] Replace
`$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain
`sprintf`/heredoc query string. Delete the two packages from `sprintf`/heredoc query string. Delete the two packages from
`composer.json`, `config/bundles.php`, and delete `composer.json`, `config/bundles.php`, and delete
`config/packages/eight_points_guzzle.yaml` + `config/packages/eight_points_guzzle.yaml` +
`config/packages/idci_graphql_client.yaml`. Run `composer update` and `config/packages/idci_graphql_client.yaml`. Run `composer update` and
commit the regenerated `composer.lock`. commit the regenerated `composer.lock`.
- [x] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, - [x] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41, 53) and `GiteaProvider.php` (lines 42, 54) each call
53) and `GiteaProvider.php` (lines 42, 54) each call `rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in
`rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in `fetch()`. Compute it once as a `private readonly string $baseUrl` in
`fetch()`. Compute it once as a `private readonly string $baseUrl` in the constructor instead.
the constructor instead.
## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4) ## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4)
- [x] `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 pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and
`_instanceof: App\Service\ProviderInterface`, left over from the `_instanceof: App\Service\ProviderInterface`, left over from the
`Service/``Provider/`+`Renderer/` namespace reorg. Update all of `Service/``Provider/`+`Renderer/` namespace reorg. Update all of
these to `App\Service\Provider\...`. Currently these bindings silently these to `App\Service\Provider\...`. Currently these bindings silently
no-op, and scalar constructor args can't autowire without them — any 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 edit to these constructors (steps 1, 4) needs this fixed first, or the
container fails to compile. container fails to compile.
## 3. `ContributionStore` (SQLite via native PDO) ## 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) > [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 - [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at
`%kernel.project_dir%/var/data/contributions.db` (configurable `%kernel.project_dir%/var/data/contributions.db` (configurable
constructor arg), table created lazily. **Schema note:** `date` is constructor arg), table created lazily. **Schema note:** `date` is
stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the
current implementation: current implementation:
```sql ```sql
CREATE TABLE IF NOT EXISTS contributions ( CREATE TABLE IF NOT EXISTS contributions (
provider TEXT NOT NULL, provider TEXT NOT NULL,
@@ -59,56 +58,56 @@ Order matters — later steps assume earlier ones are done.
) WITHOUT ROWID, STRICT; ) WITHOUT ROWID, STRICT;
``` ```
- [x] `add(string $provider, int $unixtime, int $count): void` — plain - [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 - [x] Fix `add()`: currently a plain `INSERT`, so re-adding an existing
`(provider, date)` throws a unique-constraint violation instead of `(provider, date)` throws a unique-constraint violation instead of
upserting. Switch to upserting. Switch to
`INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count` `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`
(see SQLite upsert doc above), or keep `add()` insert-only and add a (see SQLite upsert doc above), or keep `add()` insert-only and add a
separate `merge()` for the upsert case used by step 5. separate `merge()` for the upsert case used by step 5.
- [x] `remove(string $provider, int $unixtime): void` — started, has a - [x] `remove(string $provider, int $unixtime): void` — started, has a
bug: `DELETE contributions WHERE ...` is invalid SQL, missing the bug: `DELETE contributions WHERE ...` is invalid SQL, missing the
`FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also `FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also
drop the `LIKE` on `provider` (exact match, use `=`) since it's an drop the `LIKE` on `provider` (exact match, use `=`) since it's an
unindexed wildcard scan for what should be an exact key lookup. unindexed wildcard scan for what should be an exact key lookup.
- [x] **`Contribution` entity.** Small immutable value object - [x] **`Contribution` entity.** Small immutable value object
(`src/Entity/Contribution.php`) wrapping one row: `provider` (string), (`src/Entity/Contribution.php`) wrapping one row: `provider` (string),
`date` (unix timestamp int, or `\DateTimeImmutable` — pick one and `date` (unix timestamp int, or `\DateTimeImmutable` — pick one and
use it consistently everywhere, including `add()`/`all()`), `count` use it consistently everywhere, including `add()`/`all()`), `count`
(int). Gives `ContributionStore::all()` and the aggregator something (int). Gives `ContributionStore::all()` and the aggregator something
typed to pass around instead of raw arrays/tuples. typed to pass around instead of raw arrays/tuples.
- [x] **`ContributionCollection`.** Typed collection - [x] **`ContributionCollection`.** Typed collection
(`src/Entity/ContributionCollection.php`) wrapping (`src/Entity/ContributionCollection.php`) wrapping
`array<Contribution>` — implement `IteratorAggregate` + `Countable` `array<Contribution>` — implement `IteratorAggregate` + `Countable`
at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php), at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php),
[`Countable`](https://www.php.net/manual/en/class.countable.php)) so [`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 it can be `foreach`'d and `count()`'d like a normal array. This is
what `ContributionStore::all()` should return instead of a bare what `ContributionStore::all()` should return instead of a bare
`date => count` array. `date => count` array.
- [x] `latestDate(string $provider): ?int` — `SELECT MAX(date) WHERE provider = ?` - [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 - [x] `merge(string $provider, array $dateCounts): void` — upsert via
`INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`; `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`;
`$dateCounts` keyed by unix timestamp. `$dateCounts` keyed by unix timestamp.
- [x] `all(string $provider, ?int $sinceDays = null): ContributionCollection` — - [x] `all(string $provider, ?int $sinceDays = null): ContributionCollection` —
fetch all rows for a provider, or since a given range. `null` returns fetch all rows for a provider, or since a given range. `null` returns
full history (no arbitrary cutoff), a value filters to rows where full history (no arbitrary cutoff), a value filters to rows where
`date >= (now - sinceDays * 86400)`. **Bug:** current stub in `date >= (now - sinceDays * 86400)`. **Bug:** current stub in
`ContributionStore.php:58` has invalid PHP (`$provider == null` instead `ContributionStore.php:58` has invalid PHP (`$provider == null` instead
of `?string $provider = null` — this won't parse) and returns `void` of `?string $provider = null` — this won't parse) and returns `void`
instead of `ContributionCollection`; fix the signature when implementing. instead of `ContributionCollection`; fix the signature when implementing.
- [x] `prune(): void``DELETE FROM contributions WHERE date < ?` using - [x] `prune(): void``DELETE FROM contributions WHERE date < ?` using
the configured retention window (a unix timestamp cutoff); no-ops if the configured retention window (a unix timestamp cutoff); no-ops if
retention is unset/0. retention is unset/0.
- [x] Constructor takes `?int $retentionDays` bound from new env var - [x] Constructor takes `?int $retentionDays` bound from new env var
`CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). *(constructor `CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). _(constructor
param wired; the env binding itself is step 6's job.)* param wired; the env binding itself is step 6's job.)_
- [x] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store & - [x] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store &
retrieve, upsert overwrites existing date, `sinceDays` filtering, retrieve, upsert overwrites existing date, `sinceDays` filtering,
`prune()` no-ops when retention unset, `prune()` deletes rows older `prune()` no-ops when retention unset, `prune()` deletes rows older
than the window when set. than the window when set.
- [x] Tests: `tests/Unit/Entity/ContributionTest.php` and - [x] Tests: `tests/Unit/Entity/ContributionTest.php` and
`ContributionCollectionTest.php` — construction, iteration, `count()`. `ContributionCollectionTest.php` — construction, iteration, `count()`.
## 4. Wire `$since`/`$until` through providers ## 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. "catch it better" was never on the table.
- [ ] `ProviderInterface::startFetch()``startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed` - [ ] `ProviderInterface::startFetch()``startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed`
(interface already split into `startFetch`/`resolveFetch` for (interface already split into `startFetch`/`resolveFetch` for
concurrency — this doc previously said `fetch()`, which predates that concurrency — this doc previously said `fetch()`, which predates that
split). split).
- [ ] `GitHubProvider::startFetch()` — already builds explicit `from`/`to` - [ ] `GitHubProvider::startFetch()` — already builds explicit `from`/`to`
GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded
`-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`. `-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`.
- [ ] `GitLabProvider::startFetch()` — add a `before` query param alongside - [ ] `GitLabProvider::startFetch()` — add a `before` query param alongside
the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`; the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`;
`$since` already flows into `after` (this is what actually shrinks the `$since` already flows into `after` (this is what actually shrinks the
pagination loop). pagination loop).
- [ ] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query - [ ] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query
params (always returns full history); add an `$until` upper-bound params (always returns full history); add an `$until` upper-bound
filter alongside the existing `$cutoff` lower bound. filter alongside the existing `$cutoff` lower bound.
- [ ] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`, - [ ] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`,
`GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until` `GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until`
narrows the request window/query params. narrows the request window/query params.
## 5. Wire `ContributionStore` into `ContributionAggregator` ## 5. Wire `ContributionStore` into `ContributionAggregator`
- [ ] Fix `ContributionStore` wiring in `config/services.yaml` first — - [ ] Fix `ContributionStore` wiring in `config/services.yaml` first —
it's currently dead code (nothing calls it). The constructor default it's currently dead code (nothing calls it). The constructor default
`$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is `$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is
a plain PHP default, not a resolved container parameter; bind it a plain PHP default, not a resolved container parameter; bind it
explicitly (same pattern as the provider bindings): explicitly (same pattern as the provider bindings):
```yaml ```yaml
App\Service\ContributionStore: App\Service\ContributionStore:
arguments: arguments:
$dbPath: '%kernel.project_dir%/var/data/contributions.db' $dbPath: "%kernel.project_dir%/var/data/contributions.db"
$retentionDays: '%env(int:CONTRIBUTIONS_RETENTION_DAYS)%' $retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%"
``` ```
Add `env(CONTRIBUTIONS_RETENTION_DAYS): ''` to `parameters:` (empty → Add `env(CONTRIBUTIONS_RETENTION_DAYS): ''` to `parameters:` (empty →
casts to `0` → `prune()`'s existing `retentionDays === 0` check already casts to `0` → `prune()`'s existing `retentionDays === 0` check already
treats that as "keep forever"). Document the var in `.env`. treats that as "keep forever"). Document the var in `.env`.
- [ ] Inject `ContributionStore` into `ContributionAggregator`. - [ ] Inject `ContributionStore` into `ContributionAggregator`.
- [ ] Per configured provider: `$latest = $store->latestDate($name)` → - [ ] Per configured provider: `$latest = $store->latestDate($name)` →
`$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null` `$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null`
(3-day overlap for late corrections — old stored days are immutable and (3-day overlap for late corrections — old stored days are immutable and
never re-fetched, only this trailing window + anything new hits the never re-fetched, only this trailing window + anything new hits the
network), else `null` (first run, provider's own default lookback). network), else `null` (first run, provider's own default lookback).
`$until = null` (always "up to now" on the normal request path). `$until = null` (always "up to now" on the normal request path).
- [ ] `startFetch($since, $until)` / `resolveFetch()` as today, - [ ] `startFetch($since, $until)` / `resolveFetch()` as today,
`$store->merge($name, $fresh)` on success, then read back `$store->merge($name, $fresh)` on success, then read back
`$store->all($name, sinceDays: 371)` for the render window `$store->all($name, sinceDays: 371)` for the render window
(53 weeks × 7 days) — a `ContributionCollection` — and merge its (53 weeks × 7 days) — a `ContributionCollection` — and merge its
contributions into the returned array (the store becomes the source contributions into the returned array (the store becomes the source
of truth for what gets rendered, not the fresh fetch alone). of truth for what gets rendered, not the fresh fetch alone).
- [ ] Call `$store->prune()` once per `aggregate()` call, after all - [ ] 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 - [ ] 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 - [ ] Update `ContributionAggregatorTest.php` with store-interaction
(`latestDate` consulted, `merge` called, `all()` feeds the result, (`latestDate` consulted, `merge` called, `all()` feeds the result,
`prune()` runs once) and a case confirming a provider failure leaves `prune()` runs once) and a case confirming a provider failure leaves
other providers' stored data intact. other providers' stored data intact.
## 6. Docker / env ## 6. Docker / env
- [ ] `docker-compose.yml` — add a `data` named volume mounted at - [ ] `docker-compose.yml` — add a `data` named volume mounted at
`/app/var/data` (same pattern as `cache`/`logs`), and pass through `/app/var/data` (same pattern as `cache`/`logs`), and pass through
`CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`. `CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`.
- [ ] `Dockerfile` — add `var/data` to the `mkdir -p` in the `final` stage - [ ] `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), - [ ] `.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 ## 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. incremental `latestDate()` window entirely for the providers/range given.
- [ ] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` + - [ ] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` +
constructor-promoted readonly properties (matches this codebase's constructor-promoted readonly properties (matches this codebase's
attribute-first style), using `SymfonyStyle` attribute-first style), using `SymfonyStyle`
([console styling guide](https://symfony.com/doc/current/console/style.html)). ([console styling guide](https://symfony.com/doc/current/console/style.html)).
Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`, Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`,
same as `ContributionAggregator`) and `ContributionStore`. same as `ContributionAggregator`) and `ContributionStore`.
- [ ] Options: `--provider=github,gitlab` (repeatable/comma-split, - [ ] Options: `--provider=github,gitlab` (repeatable/comma-split,
restricts to named provider(s), default = all configured; unknown name restricts to named provider(s), default = all configured; unknown name
→ `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default → `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default
today 365 days), `--to=YYYY-MM-DD` (default today), `--all` (shorthand 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 for `--from` far enough back — e.g. 2005-01-01 — to mean "existing full
history"; combinable with `--provider`). history"; combinable with `--provider`).
- [ ] **Batch by range**: split `[from, to]` into ≤365-day chunks - [ ] **Batch by range**: split `[from, to]` into ≤365-day chunks
(GitHub's GraphQL `contributionsCollection` rejects windows over a (GitHub's GraphQL `contributionsCollection` rejects windows over a
year; chunking also caps GitLab's pagination per call, so a big year; chunking also caps GitLab's pagination per call, so a big
`--all` re-fetch can't reintroduce the original runaway-pagination `--all` re-fetch can't reintroduce the original runaway-pagination
timeout). Iterate chunks oldest-first. timeout). Iterate chunks oldest-first.
- [ ] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)` - [ ] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)`
/ `resolveFetch()`, `$store->merge($name, $result)` immediately (don't / `resolveFetch()`, `$store->merge($name, $result)` immediately (don't
accumulate all chunks in memory), `ProgressBar` across chunks. accumulate all chunks in memory), `ProgressBar` across chunks.
- [ ] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`, - [ ] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`,
continue with remaining chunks/providers (same graceful-degradation continue with remaining chunks/providers (same graceful-degradation
philosophy as `ContributionAggregator`). philosophy as `ContributionAggregator`).
- [ ] Finish with `$io->table(...)` summary (provider, days written, - [ ] Finish with `$io->table(...)` summary (provider, days written,
chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit
`Command::SUCCESS` if at least one provider fully succeeded, else `Command::SUCCESS` if at least one provider fully succeeded, else
`Command::FAILURE`. `Command::FAILURE`.
- [ ] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php` - [ ] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php`
using `CommandTester` with fake `ProviderInterface` stubs and a using `CommandTester` with fake `ProviderInterface` stubs and a
`:memory:` `ContributionStore` — assert chunking count for a >365-day `:memory:` `ContributionStore` — assert chunking count for a >365-day
range, store ends up populated, unknown `--provider` name fails range, store ends up populated, unknown `--provider` name fails
cleanly, a provider throwing doesn't abort the others. cleanly, a provider throwing doesn't abort the others.
## 10. PHPStan ## 10. PHPStan
- [ ] `composer require --dev phpstan/phpstan` (plain PHPStan — no - [x] `composer require --dev phpstan/phpstan` (plain PHPStan — no
Symfony extension needed for this app's size). Symfony extension needed for this app's size).
- [ ] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`. - [x] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`.
- [ ] Add composer script `"phpstan": "phpstan analyse"`. - [ ] 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 ## 11. `CLAUDE.md` update
- [ ] Architecture section: add the `ContributionStore` (SQLite) tier - [ ] Architecture section: add the `ContributionStore` (SQLite) tier
between providers and the renderer; note the two-tier cache (1h SVG between providers and the renderer; note the two-tier cache (1h SVG
cache → SQLite raw-data store → provider APIs). cache → SQLite raw-data store → provider APIs).
- [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`. - [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`.
- [ ] Document `app:contributions:refetch` (options, batching behavior). - [ ] Document `app:contributions:refetch` (options, batching behavior).
- [ ] Development section: add `composer phpstan` next to the existing - [ ] 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. - [ ] Remove any remaining mentions of the two deleted bundles.
## Verification ## Verification