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