9 Commits
Author SHA1 Message Date
haylan ac515400e0 chore(phpstan): remove PHP-Stan-Errors.md
All findings it catalogued are resolved; drop the file rather than
keep a stale error log around.
2026-07-12 23:44:32 +02:00
haylan be6146d5ad 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.
2026-07-12 23:44:09 +02:00
haylan 20e943a2b5 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.
2026-07-12 23:44:04 +02:00
haylan cf7bd8d70c docs(readme): correct outdated deploy docs and docker-compose examples
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.
2026-07-12 23:43:59 +02:00
haylan de831beaaa chore(phpstan): raise analyser memory limit and record resolved findings
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.
2026-07-12 23:43:52 +02:00
haylan 9c028aaf5e 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.
2026-07-12 23:43:31 +02:00
haylan 8e349c961b 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<int, Contribution> property
type; re-index with array_values(). Also documents the iterator/count
accessors.
2026-07-12 23:43:01 +02:00
haylan 14ea788522 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).
2026-07-12 23:42:50 +02:00
haylan 4ff45b7c46 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.
2026-07-12 23:42:41 +02:00
30 changed files with 862 additions and 156 deletions
+4
View File
@@ -19,3 +19,7 @@ GITLAB_URL=
GITEA_USER= GITEA_USER=
GITEA_TOKEN= GITEA_TOKEN=
GITEA_URL= 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
+21 -9
View File
@@ -99,6 +99,9 @@ vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Run tests matching a filter # Run tests matching a filter
vendor/bin/phpunit --filter it_renders vendor/bin/phpunit --filter it_renders
# Static analysis (PHPStan level 8)
composer phpstan
``` ```
On Windows, run via WSL (If Docker Desctop is not): 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 ## 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 GET /graph.svg?theme=dark|light
└─ GraphController └─ GraphController
├─ host check (ALLOWED_HOSTS env, optional) ├─ host check (ALLOWED_HOSTS env, optional)
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}") ├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
│ └─ on miss: │ └─ on miss: ContributionAggregator::aggregate()
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar query) │ ├─ per configured provider: ContributionStore::latestDate($name)
├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page) │ → $since = latest - 3 days (trailing overlap), or null on first run
─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap ─ GitHubProvider → GitHub GraphQL API (contributionCalendar query, bounded by $since/$until)
each returns array<string, int> (Y-m-d => count) ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page, `after`/`before` bounded)
failures are caught and logged; remaining providers still render ├─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap (filtered client-side by $since/$until)
└─ merge by date (sum counts across providers) │ each returns array<string, int> (Y-m-d => count)
│ │ failures are caught and logged; remaining providers still render
│ ├─ ContributionStore::merge() persists fresh data per provider (SQLite upsert)
│ ├─ ContributionStore::all(sinceDays: 371) reads back the render window (source of truth)
│ ├─ ContributionStore::prune() drops rows older than CONTRIBUTIONS_RETENTION_DAYS
│ └─ SvgRenderer::render() │ └─ SvgRenderer::render()
└─ Response: image/svg+xml, Cache-Control: public max-age=3600 └─ 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, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's exact colour tokens. No external assets — the SVG is fully self-contained. **SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 contributions → level 0, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's exact colour tokens. No external assets — the SVG is fully self-contained.
**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 ## Environment variables
@@ -224,5 +235,6 @@ GET /graph.svg?theme=dark|light
| `GITLAB_URL` | No | Defaults to `https://gitlab.com` | | `GITLAB_URL` | No | Defaults to `https://gitlab.com` |
| `GITEA_USER` / `GITEA_TOKEN` / `GITEA_URL` | For Gitea | Token scope: `read:user` | | `GITEA_USER` / `GITEA_TOKEN` / `GITEA_URL` | For Gitea | Token scope: `read:user` |
| `ALLOWED_HOSTS` | No | Comma-separated hostnames; empty = allow all | | `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. Copy `.env` to `.env.local` for local development — `.env.local` is gitignored.
+1 -1
View File
@@ -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/ COPY --link docker/php/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
RUN chmod +x bin/console && \ 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 chown -R app:app /app /config /data
USER app USER app
+68 -12
View File
@@ -40,10 +40,14 @@ services:
env_file: env_file:
- .env.local - .env.local
volumes: volumes:
- cache:/var/www/html/var/cache - cache:/app/var/cache/prod/pools
- logs:/app/var/log
- data:/app/var/data
volumes: volumes:
cache: cache:
logs:
data:
``` ```
The image is published to the Gitea container registry. Pull it manually with: 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 # Optional: restrict to specific hostnames (comma-separated), leave empty to allow all
ALLOWED_HOSTS= 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. 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 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 ### 4. Verify
```bash ```bash
curl http://localhost:8080/health 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 ## API
@@ -201,28 +219,43 @@ vendor/bin/phpunit
vendor/bin/phpunit --testdox vendor/bin/phpunit --testdox
# Single file # Single file
vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php vendor/bin/phpunit tests/Unit/Service/Renderer/SvgRendererTest.php
# Filter by name # Filter by name
vendor/bin/phpunit --filter it_renders 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 ## 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 GET /graph.svg?theme=dark|light
└─ GraphController └─ GraphController
├─ host check (ALLOWED_HOSTS env, optional) ├─ host check (ALLOWED_HOSTS env, optional)
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}") ├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
│ └─ on miss: │ └─ on miss: ContributionAggregator::aggregate()
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar) │ ├─ per configured provider: ContributionStore::latestDate($name)
├─ GitLabProvider → GitLab REST API (/users/:id/events, paginated) │ → $since = latest - 3 days (trailing overlap), or null on first run
─ GiteaProvider → Gitea REST API (/users/:user/heatmap) ─ GitHubProvider → GitHub GraphQL API (contributionCalendar query, bounded by $since/$until)
each returns array<string, int> (Y-m-d => count) ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page, `after`/`before` bounded)
failures are caught and logged; remaining providers still render ├─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap (filtered client-side by $since/$until)
└─ merge by date (sum counts across providers) │ each returns array<string, int> (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() │ └─ SvgRenderer::render()
└─ Response: image/svg+xml, Cache-Control: public max-age=3600 └─ 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, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained. **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, 13 → 1, 46 → 2, 79 → 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.
--- ---
+31 -30
View File
@@ -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 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` - [x] `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` - [x] `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 - [x] `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 - [x] `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`, - [x] 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 — - [x] 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
@@ -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 → 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`. - [x] Inject `ContributionStore` into `ContributionAggregator`.
- [ ] Per configured provider: `$latest = $store->latestDate($name)` → - [x] 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, - [x] `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 - [x] 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 - [x] 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 - [x] 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 - [x] `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 - [x] `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), - [x] `.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. `graph:contributions:refetch` console command ## 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 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]` + - [x] `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, - [x] 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 - [x] **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)` - [x] 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(...)`, - [x] 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, - [x] 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` - [x] 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
@@ -228,20 +228,21 @@ incremental `latestDate()` window entirely for the providers/range given.
- [x] `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).
- [x] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`. - [x] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`.
- [ ] Add composer script `"phpstan": "phpstan analyse"`. - [x] Add composer script `"phpstan": "phpstan analyse"`.
- [ ] Update README.md docs with new command and php stan static lintin. - [x] 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] 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 - [x] 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`. - [x] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`.
- [ ] Document `app:contributions:refetch` (options, batching behavior). - [x] Document `app:contributions:refetch` (options, batching behavior).
- [ ] Development section: add `composer phpstan` next to the existing - [x] 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. - [x] Remove any remaining mentions of the two deleted bundles. (none found —
already clean)
## Verification ## Verification
+3
View File
@@ -41,5 +41,8 @@
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "7.4.*"
} }
},
"scripts": {
"phpstan": "phpstan analyse --memory-limit=512M"
} }
} }
+1
View File
@@ -9,6 +9,7 @@ parameters:
env(GITEA_USER): "" env(GITEA_USER): ""
env(GITEA_TOKEN): "" env(GITEA_TOKEN): ""
env(GITEA_URL): "" env(GITEA_URL): ""
env(CONTRIBUTIONS_RETENTION_DAYS): "0"
services: services:
_defaults: _defaults:
+3
View File
@@ -18,9 +18,11 @@ services:
GITEA_USER: "${GITEA_USER:-}" GITEA_USER: "${GITEA_USER:-}"
GITEA_TOKEN: "${GITEA_TOKEN:-}" GITEA_TOKEN: "${GITEA_TOKEN:-}"
GITEA_URL: "${GITEA_URL:-}" GITEA_URL: "${GITEA_URL:-}"
CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}"
volumes: volumes:
- cache:/app/var/cache/prod/pools - cache:/app/var/cache/prod/pools
- logs:/app/var/log - logs:/app/var/log
- data:/app/var/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
@@ -30,3 +32,4 @@ services:
volumes: volumes:
cache: cache:
logs: logs:
data:
+3
View File
@@ -20,9 +20,11 @@ services:
GITEA_USER: "${GITEA_USER:-}" GITEA_USER: "${GITEA_USER:-}"
GITEA_TOKEN: "${GITEA_TOKEN:-}" GITEA_TOKEN: "${GITEA_TOKEN:-}"
GITEA_URL: "${GITEA_URL:-}" GITEA_URL: "${GITEA_URL:-}"
CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}"
volumes: volumes:
- cache:/app/var/cache/prod/pools - cache:/app/var/cache/prod/pools
- logs:/app/var/log - logs:/app/var/log
- data:/app/var/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
@@ -33,3 +35,4 @@ services:
volumes: volumes:
cache: cache:
logs: logs:
data:
+157 -20
View File
@@ -4,51 +4,188 @@ declare(strict_types=1);
namespace GitContributionGraph\Command; namespace GitContributionGraph\Command;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
#[AsCommand(name: 'graph:contributions:refetch')] #[AsCommand(
class RefetchContributionsCommand extends Command 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; private SymfonyStyle $io;
/** @param iterable<ProviderInterface> $providers */
public function __construct( public function __construct(
#[AutowireIterator('app.provider')] #[AutowireIterator('app.provider')]
private readonly iterable $providers, private readonly iterable $providers,
private readonly ContributionStore $store,
) { ) {
parent::__construct(); parent::__construct();
} }
/** Symfony console lifecycle hook: sets up the styled I/O helper used throughout the command. */
public function initialize(InputInterface $input, OutputInterface $output) public function initialize(InputInterface $input, OutputInterface $output): void
{ {
$this->io = new SymfonyStyle($input, $output); $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( public function __invoke(
//TODO: check if its possible to add the preview with real strings from the providers. #[Option('Comma-separated provider names to refetch (default: all configured)', 'provider', 'p')]
#[Option("(repeatable/comma-split, restricts to named provider(s), default = all configured", "provider", "p", "")] string $provider = '',
string $providers = "" #[Option('Start date (YYYY-MM-DD), default: 365 days ago', 'from')]
) { string $from = '',
$requested = $providers === '' ? null : explode(',', $providers); #[Option('End date (YYYY-MM-DD), default: today', 'to')]
$byName = []; string $to = '',
#[Option('Refetch full history (from ' . self::ALL_SINCE . ')', 'all')]
foreach ($this->providers as $provider) { bool $all = false,
$byName[$provider->getName()] = $provider; ): int {
} $byName = $this->resolveProviders($provider);
if ($byName === null) {
if ($requested !== null) {
foreach ($requested as $name) {
if (!isset($byName[$name])) {
$this->io->error("Unknown provider: {$name}");
return Command::FAILURE; 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<array{0: \DateTimeImmutable, 1: \DateTimeImmutable}> $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<string, ProviderInterface>|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<array{0: \DateTimeImmutable, 1: \DateTimeImmutable}>
*/
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;
} }
} }
+18 -2
View File
@@ -34,8 +34,12 @@ final class GraphController
} }
/** /**
* Query parameters: * Serves the contribution heatmap SVG, from a 1-hour cache on repeat requests.
* theme string dark|light (default: dark) *
* 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'])] #[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])]
public function graph(Request $request): Response 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'])] #[Route('/', name: 'index', methods: ['GET'])]
public function index(Request $request): RedirectResponse public function index(Request $request): RedirectResponse
{ {
@@ -75,6 +85,12 @@ final class GraphController
return new RedirectResponse($url, 302); 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'])] #[Route('/health', name: 'health', methods: ['GET'])]
public function health(): Response public function health(): Response
{ {
+6
View File
@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace GitContributionGraph\Entity; namespace GitContributionGraph\Entity;
/** A single provider's contribution count for one day, as stored in {@see \GitContributionGraph\Service\ContributionStore}. */
final class Contribution 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 function __construct(
public readonly string $provider, public readonly string $provider,
public readonly int $date, public readonly int $date,
+3 -1
View File
@@ -14,14 +14,16 @@ final class ContributionCollection implements \IteratorAggregate, \Countable
public function __construct(Contribution ...$contributions) public function __construct(Contribution ...$contributions)
{ {
$this->contributions = $contributions; $this->contributions = array_values($contributions);
} }
/** @return \ArrayIterator<int, Contribution> */
public function getIterator(): \ArrayIterator public function getIterator(): \ArrayIterator
{ {
return new \ArrayIterator($this->contributions); return new \ArrayIterator($this->contributions);
} }
/** Number of contributions held in this collection. */
public function count(): int public function count(): int
{ {
return count($this->contributions); return count($this->contributions);
+50 -12
View File
@@ -10,25 +10,64 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ContributionAggregator final class ContributionAggregator
{ {
private const RENDER_WINDOW_DAYS = 371;
private const OVERLAP_DAYS = 3;
/** @param iterable<ProviderInterface> $providers */
public function __construct( public function __construct(
#[AutowireIterator('app.provider')] #[AutowireIterator('app.provider')]
private readonly iterable $providers, private readonly iterable $providers,
private readonly ContributionStore $store,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) {} ) {}
/** @return array<string, int> */ /**
* 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<string, int> date (Y-m-d) => contribution count, summed across all providers
*/
public function aggregate(): array public function aggregate(): array
{ {
$pending = []; $configured = [];
/** @var ProviderInterface $provider */ /** @var ProviderInterface $provider */
foreach ($this->providers as $provider) { foreach ($this->providers as $provider) {
if (!$provider->isConfigured()) { if ($provider->isConfigured()) {
continue; $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 { 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) { } catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]); $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
} }
@@ -36,16 +75,15 @@ final class ContributionAggregator
$contributions = []; $contributions = [];
foreach ($pending as [$provider, $handle]) { foreach ($configured as $provider) {
try { foreach ($this->store->all($provider->getName(), sinceDays: self::RENDER_WINDOW_DAYS) as $contribution) {
foreach ($provider->resolveFetch($handle) as $date => $count) { $date = (new \DateTimeImmutable('@' . $contribution->date))->format('Y-m-d');
$contributions[$date] = ($contributions[$date] ?? 0) + $count; $contributions[$date] = ($contributions[$date] ?? 0) + $contribution->count;
}
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
} }
} }
$this->store->prune();
return $contributions; return $contributions;
} }
} }
+22 -1
View File
@@ -15,6 +15,9 @@ class ContributionStore
/** /**
* Opens (creating if needed) the SQLite store at $dbPath and ensures the schema exists. * 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( public function __construct(
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db", 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. * 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 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. * 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 public function remove(string $provider, int $unixtime): void
{ {
@@ -69,6 +79,7 @@ class ContributionStore
/** /**
* Upserts a batch of date => count pairs for a provider via repeated add() calls. * Upserts a batch of date => count pairs for a provider via repeated add() calls.
* *
* @param string $provider provider identifier, e.g. "github"
* @param array<int, int> $dateCounts unix timestamp => count * @param array<int, int> $dateCounts unix timestamp => count
*/ */
public function merge(string $provider, array $dateCounts): void 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. * 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 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. * 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 public function all(string $provider, ?int $sinceDays = null): ContributionCollection
{ {
@@ -106,9 +124,12 @@ class ContributionStore
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
/** @var array<int, array{provider: string, date: int|string, count: int|string}> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$contributions = array_map( $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), $rows,
); );
return new ContributionCollection(...$contributions); return new ContributionCollection(...$contributions);
+7 -3
View File
@@ -44,12 +44,13 @@ final class GitHubProvider implements ProviderInterface
])->getContent(); ])->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]); $this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]);
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); $from = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); $to = ($until ?? new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
$query = sprintf( $query = sprintf(
'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }', 'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }',
@@ -71,6 +72,9 @@ final class GitHubProvider implements ProviderInterface
} }
/** /**
* Parses the GraphQL response into a per-day count map, skipping zero-count days.
*
* @param ResponseInterface $handle the response returned by startFetch()
* @return array<string, int> date (Y-m-d) => contribution count * @return array<string, int> date (Y-m-d) => contribution count
*/ */
public function resolveFetch(mixed $handle): array public function resolveFetch(mixed $handle): array
+23 -15
View File
@@ -50,14 +50,16 @@ 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 * ponytail: the user-id lookup and event pagination stay sequential within
* this one provider (each page depends on the previous). Parallelism here * this one provider (each page depends on the previous). Parallelism here
* only spans across providers; revisit only if GitLab pagination itself * only spans across providers; revisit only if GitLab pagination itself
* becomes the bottleneck. * 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]); $this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
@@ -72,26 +74,31 @@ final class GitLabProvider implements ProviderInterface
throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $this->baseUrl"); throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $this->baseUrl");
} }
$userId = $users[0]['id']; $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", [ $response = $this->client->request('GET', "$this->baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token], 'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [ 'query' => $query,
'after' => $after,
'per_page' => 100,
'page' => 1,
],
]); ]);
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];
} }
/** /**
* 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<string, int> date (Y-m-d) => event count * @return array<string, int> date (Y-m-d) => event count
*/ */
public function resolveFetch(mixed $handle): array 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 = []; $result = [];
@@ -106,13 +113,14 @@ final class GitLabProvider implements ProviderInterface
$page++; $page++;
if (count($events) === 100) { 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", [ $response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token], 'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [ 'query' => $query,
'after' => $after,
'per_page' => 100,
'page' => $page,
],
]); ]);
} }
} while (count($events) === 100); } while (count($events) === 100);
+21 -7
View File
@@ -51,31 +51,45 @@ final class GiteaProvider implements ProviderInterface
])->getContent(); ])->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]); $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}"], 'headers' => ['Authorization' => "token {$this->token}"],
]); ]);
return ['response' => $response, 'since' => $since, 'until' => $until];
} }
/** /**
* 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<string, int> date (Y-m-d) => contribution count * @return array<string, int> date (Y-m-d) => contribution count
*/ */
public function resolveFetch(mixed $handle): array public function resolveFetch(mixed $handle): array
{ {
/** @var ResponseInterface $handle */ ['response' => $response, 'since' => $since, 'until' => $until] = $handle;
$data = $handle->toArray();
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp(); /** @var ResponseInterface $response */
$data = $response->toArray();
$cutoff = ($since ?? new \DateTimeImmutable('-365 days'))->getTimestamp();
$ceiling = $until?->getTimestamp();
$result = []; $result = [];
foreach ($data as $entry) { foreach ($data as $entry) {
if ($entry['timestamp'] < $cutoff) { if ($entry['timestamp'] < $cutoff) {
continue; continue;
} }
if ($ceiling !== null && $entry['timestamp'] > $ceiling) {
continue;
}
$date = date('Y-m-d', $entry['timestamp']); $date = date('Y-m-d', $entry['timestamp']);
$result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions']; $result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions'];
} }
+4
View File
@@ -9,6 +9,10 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
trait ProbeTrait 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 public function probe(): ProviderStatus
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
@@ -8,13 +8,17 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ProviderHealthChecker final class ProviderHealthChecker
{ {
/** @param iterable<ProviderInterface> $providers */
public function __construct( public function __construct(
#[AutowireIterator('app.provider')] #[AutowireIterator('app.provider')]
private readonly iterable $providers, private readonly iterable $providers,
) {} ) {}
/** /**
* @return array{status: string, providers: array<string, array<string, string>>} * Probes every configured provider and aggregates the results for the /health endpoint.
*
* @return array{status: string, providers: array<string, array<string, string>>} 'status' is
* "degraded" if any provider's probe() reported an error, otherwise "ok"
*/ */
public function check(): array public function check(): array
{ {
+19 -3
View File
@@ -6,17 +6,33 @@ namespace GitContributionGraph\Service\Provider;
interface ProviderInterface 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<string, int> 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<string, int> date (Y-m-d) => contribution count
*/
public function resolveFetch(mixed $handle): array; public function resolveFetch(mixed $handle): array;
/** Whether the required credentials/URL for this provider are all set. */
public function isConfigured(): bool; public function isConfigured(): bool;
/** Short lowercase identifier for this provider, e.g. "github". */
public function getName(): string; public function getName(): string;
/** Check reachability/credentials without fetching contribution data; used by the /health endpoint. */
public function probe(): ProviderStatus; public function probe(): ProviderStatus;
/** Make a lightweight authenticated request to verify the provider is reachable; throws on failure. */
public function ping(): void; public function ping(): void;
} }
+12 -1
View File
@@ -4,8 +4,15 @@ declare(strict_types=1);
namespace GitContributionGraph\Service\Provider; namespace GitContributionGraph\Service\Provider;
/** Result of probing a single provider, as reported by the /health endpoint. */
final class ProviderStatus 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 function __construct(
public readonly string $name, public readonly string $name,
public readonly ProviderStatusType $status, public readonly ProviderStatusType $status,
@@ -13,7 +20,11 @@ final class ProviderStatus
public readonly ?string $message = null, public readonly ?string $message = null,
) {} ) {}
/** @return array<string, string> */ /**
* Converts to the array shape used in the /health JSON response, omitting error/message when unset.
*
* @return array<string, string>
*/
public function toArray(): array public function toArray(): array
{ {
$data = ['status' => $this->status->value]; $data = ['status' => $this->status->value];
+21 -2
View File
@@ -36,6 +36,12 @@ final class SvgRenderer
private const MARGIN_Y = 20; // top margin for month labels private const MARGIN_Y = 20; // top margin for month labels
private const PADDING = 10; // outer padding private const PADDING = 10; // outer padding
/**
* Builds the full self-contained SVG heatmap for a contribution map.
*
* @param array<string, int> $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 public function render(array $contributions, string $theme = 'dark'): string
{ {
$colors = self::THEMES[$theme] ?? self::THEMES['dark']; $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] * Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int]
* or null if the date is in the future. * or null if the date is in the future.
*
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @return array<int, array<int, array{date: string, count: int}|null>>
*/ */
private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array
{ {
@@ -125,6 +134,10 @@ final class SvgRenderer
}; };
} }
/**
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @return array{total: int}
*/
private function computeStats(array $contributions): array private function computeStats(array $contributions): array
{ {
return ['total' => array_sum($contributions)]; return ['total' => array_sum($contributions)];
@@ -132,6 +145,7 @@ final class SvgRenderer
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** @param array<int, array<int, array{date: string, count: int}|null>> $grid */
private function renderMonthLabels(array $grid, string $textColor): string private function renderMonthLabels(array $grid, string $textColor): string
{ {
$out = ''; $out = '';
@@ -142,7 +156,7 @@ final class SvgRenderer
if ($cell === null) { if ($cell === null) {
continue; continue;
} }
$ts = strtotime($cell['date']); $ts = (int) strtotime($cell['date']);
$month = (int) date('n', $ts); $month = (int) date('n', $ts);
$dom = (int) date('j', $ts); $dom = (int) date('j', $ts);
@@ -183,6 +197,10 @@ final class SvgRenderer
return $out; return $out;
} }
/**
* @param array<int, array<int, array{date: string, count: int}|null>> $grid
* @param array{bg: string, text: string, levels: array<int, string>} $colors
*/
private function renderCells(array $grid, array $colors): string private function renderCells(array $grid, array $colors): string
{ {
$out = ''; $out = '';
@@ -197,7 +215,7 @@ final class SvgRenderer
$y = self::MARGIN_Y + $d * self::STEP; $y = self::MARGIN_Y + $d * self::STEP;
$color = $colors['levels'][$this->level($cell['count'])]; $color = $colors['levels'][$this->level($cell['count'])];
$ts = strtotime($cell['date']); $ts = (int) strtotime($cell['date']);
$suffix = $cell['count'] !== 1 ? 's' : ''; $suffix = $cell['count'] !== 1 ? 's' : '';
$label = $cell['count'] > 0 $label = $cell['count'] > 0
? $cell['count'] . ' contribution' . $suffix . ' on ' . date('F j, Y', $ts) ? $cell['count'] . ' contribution' . $suffix . ' on ' . date('F j, Y', $ts)
@@ -218,6 +236,7 @@ final class SvgRenderer
return $out; return $out;
} }
/** @param array{bg: string, text: string, levels: array<int, string>} $colors */
private function renderLegend(int $totalH, array $colors): string private function renderLegend(int $totalH, array $colors): string
{ {
$y = $totalH - 14; $y = $totalH - 14;
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Command;
use GitContributionGraph\Command\RefetchContributionsCommand;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(RefetchContributionsCommand::class)]
final class RefetchContributionsCommandTest extends TestCase
{
/** @param ?array<string, int> $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<ProviderInterface> $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);
}
}
+112 -29
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service; namespace GitContributionGraph\Tests\Unit\Service;
use GitContributionGraph\Service\ContributionAggregator; use GitContributionGraph\Service\ContributionAggregator;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface; use GitContributionGraph\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@@ -15,16 +16,31 @@ use Psr\Log\LoggerInterface;
final class ContributionAggregatorTest extends TestCase final class ContributionAggregatorTest extends TestCase
{ {
private LoggerInterface $logger; private LoggerInterface $logger;
private ContributionStore $store;
protected function setUp(): void protected function setUp(): void
{ {
$this->logger = $this->createStub(LoggerInterface::class); $this->logger = $this->createStub(LoggerInterface::class);
$this->store = new ContributionStore(':memory:');
}
/** @param ?array<string, int> $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] #[Test]
public function it_returns_empty_array_when_no_providers_are_given(): void 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(); $result = $aggregator->aggregate();
@@ -34,10 +50,9 @@ final class ContributionAggregatorTest extends TestCase
#[Test] #[Test]
public function it_skips_unconfigured_providers(): void public function it_skips_unconfigured_providers(): void
{ {
$provider = $this->createStub(ProviderInterface::class); $provider = $this->makeProvider('github', configured: false);
$provider->method('isConfigured')->willReturn(false);
$aggregator = new ContributionAggregator([$provider], $this->logger); $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
@@ -47,69 +62,68 @@ final class ContributionAggregatorTest extends TestCase
#[Test] #[Test]
public function it_returns_contributions_from_a_configured_provider(): void public function it_returns_contributions_from_a_configured_provider(): void
{ {
$provider = $this->createStub(ProviderInterface::class); $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$provider->method('isConfigured')->willReturn(true); $provider = $this->makeProvider('github', fresh: [$date => 3]);
$provider->method('resolveFetch')->willReturn(['2024-01-01' => 3]);
$aggregator = new ContributionAggregator([$provider], $this->logger); $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 3], $result); $this->assertSame([$date => 3], $result);
} }
#[Test] #[Test]
public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void
{ {
$providerA = $this->createStub(ProviderInterface::class); $dateA = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$providerA->method('isConfigured')->willReturn(true); $dateB = (new \DateTimeImmutable('-2 days'))->format('Y-m-d');
$providerA->method('resolveFetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]);
$providerB = $this->createStub(ProviderInterface::class); $providerA = $this->makeProvider('github', fresh: [$dateA => 3, $dateB => 1]);
$providerB->method('isConfigured')->willReturn(true); $providerB = $this->makeProvider('gitlab', fresh: [$dateA => 2]);
$providerB->method('resolveFetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger); $aggregator = new ContributionAggregator([$providerA, $providerB], $this->store, $this->logger);
$result = $aggregator->aggregate(); $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] #[Test]
public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void 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 = $this->createStub(ProviderInterface::class);
$failing->method('getName')->willReturn('gitlab');
$failing->method('isConfigured')->willReturn(true); $failing->method('isConfigured')->willReturn(true);
$failing->method('startFetch')->willThrowException(new \RuntimeException('Network error')); $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class); $healthy = $this->makeProvider('github', fresh: [$date => 7]);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result); $this->assertSame([$date => 7], $result);
} }
#[Test] #[Test]
public function it_continues_fetching_remaining_providers_when_one_throws_on_resolve(): void 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 = $this->createStub(ProviderInterface::class);
$failing->method('getName')->willReturn('gitlab');
$failing->method('isConfigured')->willReturn(true); $failing->method('isConfigured')->willReturn(true);
$failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error')); $failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class); $healthy = $this->makeProvider('github', fresh: [$date => 7]);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result); $this->assertSame([$date => 7], $result);
} }
#[Test] #[Test]
@@ -119,10 +133,11 @@ final class ContributionAggregatorTest extends TestCase
$logger->expects($this->once())->method('warning'); $logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class); $provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn('github');
$provider->method('isConfigured')->willReturn(true); $provider->method('isConfigured')->willReturn(true);
$provider->method('startFetch')->willThrowException(new \RuntimeException('fail')); $provider->method('startFetch')->willThrowException(new \RuntimeException('fail'));
(new ContributionAggregator([$provider], $logger))->aggregate(); (new ContributionAggregator([$provider], $this->store, $logger))->aggregate();
} }
#[Test] #[Test]
@@ -132,9 +147,77 @@ final class ContributionAggregatorTest extends TestCase
$logger->expects($this->once())->method('warning'); $logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class); $provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn('github');
$provider->method('isConfigured')->willReturn(true); $provider->method('isConfigured')->willReturn(true);
$provider->method('resolveFetch')->willThrowException(new \RuntimeException('fail')); $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);
} }
} }
@@ -29,6 +29,7 @@ final class GitHubProviderTest extends TestCase
); );
} }
/** @param array<int, array{contributionDays: array<int, array{date: string, contributionCount: int}>}> $weeks */
private function stubGraphqlResponse(array $weeks): ResponseInterface private function stubGraphqlResponse(array $weeks): ResponseInterface
{ {
$response = $this->createStub(ResponseInterface::class); $response = $this->createStub(ResponseInterface::class);
@@ -131,4 +132,27 @@ final class GitHubProviderTest extends TestCase
$this->assertSame([], $result); $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);
}
} }
@@ -31,6 +31,7 @@ final class GitLabProviderTest extends TestCase
); );
} }
/** @param array<int, array<string, int|string>> $data raw JSON body, e.g. events or the user lookup */
private function stubResponse(array $data): ResponseInterface private function stubResponse(array $data): ResponseInterface
{ {
$response = $this->createStub(ResponseInterface::class); $response = $this->createStub(ResponseInterface::class);
@@ -128,4 +129,27 @@ final class GitLabProviderTest extends TestCase
$this->assertSame(100, $result['2024-06-10']); $this->assertSame(100, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']); $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']);
}
} }
@@ -30,6 +30,7 @@ final class GiteaProviderTest extends TestCase
); );
} }
/** @param array<int, array{timestamp: int, contributions: int}> $data */
private function stubResponse(array $data): ResponseInterface private function stubResponse(array $data): ResponseInterface
{ {
$response = $this->createStub(ResponseInterface::class); $response = $this->createStub(ResponseInterface::class);
@@ -111,4 +112,44 @@ final class GiteaProviderTest extends TestCase
$this->assertSame([], $result); $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);
}
} }
@@ -31,7 +31,7 @@ final class ProbeTraitTest extends TestCase
public function isConfigured(): bool { return $this->configured; } public function isConfigured(): bool { return $this->configured; }
public function getName(): string { return 'test'; } public function getName(): string { return 'test'; }
public function ping(): void { ($this->ping)(); } 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 []; } public function resolveFetch(mixed $handle): array { return []; }
}; };
} }
@@ -54,6 +54,7 @@ final class SvgRendererTest extends TestCase
$this->assertStringContainsString($expectedColor, $svg); $this->assertStringContainsString($expectedColor, $svg);
} }
/** @return iterable<string, array{string, string}> */
public static function theme_background_provider(): iterable public static function theme_background_provider(): iterable
{ {
yield 'dark theme' => ['dark', '#0d1117']; yield 'dark theme' => ['dark', '#0d1117'];
@@ -97,6 +98,7 @@ final class SvgRendererTest extends TestCase
$this->assertStringContainsString('>' . $label . '<', $svg); $this->assertStringContainsString('>' . $label . '<', $svg);
} }
/** @return iterable<string, array{string}> */
public static function day_of_week_label_provider(): iterable public static function day_of_week_label_provider(): iterable
{ {
yield 'Monday' => ['Mon']; yield 'Monday' => ['Mon'];