47 Commits
Author SHA1 Message Date
haylan fb8f25d5ff Merge pull request 'Delete TODO.md' (#14) from haylan-patch-1 into main
Reviewed-on: #14
2026-07-12 21:53:01 +00:00
haylan 34e558d31c Delete TODO.md 2026-07-12 21:52:51 +00:00
haylan c4dffb1dc6 Merge pull request 'Save to database' (#13) from save-to-database into main
Reviewed-on: #13
2026-07-12 21:51:57 +00:00
haylan 4825c45a7c chore(update): changelog 0.2.0 2026-07-12 23:51:19 +02:00
haylan ac515400e0 chore(phpstan): remove PHP-Stan-Errors.md
All findings it catalogued are resolved; drop the file rather than
keep a stale error log around.
2026-07-12 23:44:32 +02:00
haylan be6146d5ad chore(todo): mark completed roadmap items
Check off the incremental-fetch/store wiring, refetch command, phpstan
setup, and CLAUDE.md update items now that all are done.
2026-07-12 23:44:09 +02:00
haylan 20e943a2b5 docs(claude): document SQLite store, refetch command, and phpstan script
Update the architecture diagram and env var table to reflect the
ContributionStore tier and incremental fetch, document the
graph:contributions:refetch command, and note composer phpstan next to
the existing test commands.
2026-07-12 23:44:04 +02:00
haylan cf7bd8d70c docs(readme): correct outdated deploy docs and docker-compose examples
README had drifted from the codebase: wrong test path, a stale /health
sample response, a docker-compose volume mount pointing at a path the
image never writes to (with no SQLite data volume at all), a missing
CONTRIBUTIONS_RETENTION_DAYS example, and an architecture diagram
predating the SQLite store/refetch command. Also fixes
docker-compose.prod.yml, which was missing the data volume and
CONTRIBUTIONS_RETENTION_DAYS env var present in docker-compose.yml —
without it, a prod deployment on this file loses contribution history
on every container recreate.
2026-07-12 23:43:59 +02:00
haylan de831beaaa chore(phpstan): raise analyser memory limit and record resolved findings
Add --memory-limit=512M to the phpstan composer script — the default
128M crashed the level-8 run under parallel workers. Update
PHP-Stan-Errors.md to note all 22 catalogued findings are now resolved.
2026-07-12 23:43:52 +02:00
haylan 9c028aaf5e docs(phpdoc): document public methods across src/
Add professional PHPDoc (summary + @param/@return) to the remaining
public methods that had none or only partial coverage: GraphController's
actions, ContributionStore's accessors, SvgRenderer's render() and
private helpers (with concrete array-shape annotations), ProbeTrait,
ProviderHealthChecker, ProviderStatus, and the Contribution entity.
Also adds the matching @return shapes on SvgRendererTest's data
providers.
2026-07-12 23:43:31 +02:00
haylan 8e349c961b fix(entity): re-index ContributionCollection constructor arguments
array_map() over the variadic constructor can produce non-sequential or
string keys, which doesn't match the array<int, Contribution> property
type; re-index with array_values(). Also documents the iterator/count
accessors.
2026-07-12 23:43:01 +02:00
haylan 14ea788522 fix(renderer): coerce strtotime() result to int before formatting dates
date() expects int|null, but strtotime() returns int|false; cast to make
the type explicit for PHPStan level 8 (the date strings are always
well-formed here, so strtotime() never actually returns false).
2026-07-12 23:42:50 +02:00
haylan 4ff45b7c46 feat(store): fetch contributions incrementally and persist history in SQLite
Bound each provider's startFetch()/resolveFetch() to an optional
since/until window, wire a SQLite-backed ContributionStore into
ContributionAggregator (fetch only the trailing window past the last
stored date, merge into the store, prune by CONTRIBUTIONS_RETENTION_DAYS),
and add a graph:contributions:refetch command to force a full or ranged
re-fetch in <=365-day chunks. This is the root fix for the full
365-day-refetch timeout that used to hit on every cache miss.
2026-07-12 23:42:41 +02:00
haylan 41e88144a4 chore(namespace): rewirtten namspeace from app to GitContributionGraph. 2026-07-12 22:47:34 +02:00
haylan 91bda21f89 feat(phpstan): added php stan 2026-07-12 12:43:54 +02:00
haylan 0d7cf68771 chore(todo): checked php stan instalation and configuration. added document php stan erros as well to update readme docs 2026-07-12 12:43:33 +02:00
haylan e7f14dfd35 chore(doc): documentet all public methods 2026-07-12 12:27:42 +02:00
haylan 095f3e0253 feat(db): wired databas into services config 2026-07-12 12:25:00 +02:00
haylan e2a324b74a chore(todo): expanded todo to fix a live bug 2026-07-12 12:23:31 +02:00
haylan a76ed6efc8 chore(todo): mark completed tasks and expand step 3 detail 2026-07-11 16:46:50 +02:00
haylan bce564d68b docs(readme): fix phpunit command paths
There is no bin/phpunit (only bin/console) — the binary lives at
vendor/bin/phpunit, matching CLAUDE.md's documented commands.
2026-07-11 16:46:47 +02:00
haylan b3dc7a2298 fix(docker): target the dev stage when building for local development
docker-compose.override.yml only overrode the dockerfile, so it kept
inheriting target: final from docker-compose.yml — but Dockerfile.dev
is single-stage and has no final stage, so the dev build failed. Name
the stage in Dockerfile.dev and target it explicitly from the
override.
2026-07-11 16:46:29 +02:00
haylan 9087f91855 feat(store): finish ContributionStore and add typed entities
- Fix add()'s upsert SQL (was invalid SQL from typos) and use it for
  the new merge() as well.
- Fix remove()'s missing FROM keyword and drop the unindexed LIKE scan
  in favor of an exact match.
- Add latestDate(), all() with optional sinceDays filtering, and
  prune() with a retention-day cutoff.
- Add Contribution (immutable value object) and ContributionCollection
  (IteratorAggregate + Countable) so all() returns something typed
  instead of a raw date => count array.
- Cover all of the above with unit tests against an in-memory SQLite
  database.
2026-07-11 16:46:21 +02:00
haylan 885dec357e refactor(github): build GraphQL query without the client bundle
The eightpoints/guzzle-bundle and idci/graphql-client-bundle only
existed to build one near-static query string, and the bundle's own
HTTP transport was already bypassed in favor of Symfony HttpClient.
Build the query with sprintf/json_encode instead, drop both bundles
from config/bundles.php, and update the test double accordingly.
2026-07-11 16:46:11 +02:00
haylan 78e7a72ee9 fix(provider): normalize baseUrl before assigning readonly property
Promoted readonly properties can't be reassigned in the constructor
body, so GitLabProvider and GiteaProvider threw on every instantiation.
Normalize the plain parameter first, then assign to a separately
declared readonly property.
2026-07-11 16:46:04 +02:00
haylan c929fdfda6 feat(db): prepared add and remove function 2026-07-08 20:20:42 +02:00
haylan 3d2c0487b7 feat(store): fist stepts for implement ContributionStore for managing contributions in SQLite 2026-07-08 06:50:56 +02:00
haylan cb89a1c6b9 chore(docker): update volume mappings for development environment 2026-07-08 06:50:39 +02:00
haylan 5e27a5bc93 fix(provider): dedoupet base URL is properly formatted in GitLab and Gitea providers 2026-07-08 06:50:25 +02:00
haylan f52a3449c6 chore(TODO): mark completed tasks and update contribution store details 2026-07-08 06:49:28 +02:00
haylan ca94b80beb chore(composer): remove deprecated Guzzle and GraphQL client configurations 2026-07-08 06:49:18 +02:00
haylan cd326a34ba chore(controller): lower set_time_limit now that fetching is concurrent
Providers fetch in parallel now (cost ~= max, not sum), so the old
90s stopgap for the 3-sequential-providers-plus-pagination worst case
can come back down toward the 30s default, with headroom for
pagination and per-request HTTP timeouts.
2026-07-07 13:32:17 +02:00
haylan 8fb7781227 test(aggregator): cover concurrent fetch failure modes
Mocks startFetch()/resolveFetch() instead of fetch(), and splits the
old single failure test into separate start-throws and
resolve-throws cases since those are now distinct call sites.
2026-07-07 13:32:11 +02:00
haylan 1e2f022579 test(gitlab): exercise startFetch()/resolveFetch() instead of fetch() 2026-07-07 13:32:05 +02:00
haylan c89b4e9212 test(gitea): exercise startFetch()/resolveFetch() instead of fetch() 2026-07-07 13:31:59 +02:00
haylan 5c47888a49 test(github): exercise startFetch()/resolveFetch() instead of fetch() 2026-07-07 13:31:52 +02:00
haylan f2bf24994c test(provider): update ProbeTraitTest stub for split fetch API
The anonymous ProviderInterface implementation needs startFetch()/
resolveFetch() instead of fetch() to stay loadable.
2026-07-07 13:31:44 +02:00
haylan fcf841f7ad feat(aggregator): fetch all providers concurrently
aggregate() now fires every configured provider's startFetch() in one
pass before resolving any of them, so wall-clock cost is roughly
max(providers) instead of sum(providers). Partial-failure isolation
and per-date summation behavior are unchanged.
2026-07-07 13:31:36 +02:00
haylan 9b631f4777 refactor(gitlab): make GitLabProvider fetch non-blocking
startFetch() resolves the user id and fires the first events page,
returning a handle without reading it; resolveFetch() continues the
existing do/while pagination starting from that first response.

Pagination itself stays sequential within this provider since each
page depends on the previous one - parallelism here only spans across
providers, not within GitLab's own pages.
2026-07-07 13:31:30 +02:00
haylan 596260bb85 refactor(gitea): make GiteaProvider fetch non-blocking
startFetch() fires the heatmap GET and returns the response without
reading it; resolveFetch() does the .toArray()/parse/log work.
2026-07-07 13:31:21 +02:00
haylan 9f3c54afd1 refactor(github): make GitHubProvider fetch non-blocking
startFetch() fires the GraphQL POST and returns the response without
reading it; resolveFetch() does the .toArray()/parse/log work that
used to happen inline right after the request.
2026-07-07 13:31:15 +02:00
haylan 11bec43bbe refactor(provider): split fetch() into startFetch()/resolveFetch()
Splits the provider contract into a non-blocking startFetch() that
fires the HTTP request(s) and a resolveFetch() that reads the result,
so callers can fire every provider's request before blocking on any
of them. Symfony HttpClient is already async under the hood -
request() doesn't block until you read the response - so this needs
no new dependency.
2026-07-07 13:31:09 +02:00
haylan 09c0be8312 config: cap http_client timeout and max_duration
Per-request bounds so a single slow/hung provider can't stall the
whole request indefinitely once fetching happens concurrently.
2026-07-07 13:31:03 +02:00
haylan 6d6680ca37 fix(di): correct provider service FQCNs in services.yaml
Providers were moved into the App\Service\Provider namespace, but
services.yaml still tagged/configured them under the old App\Service
FQCNs, so the _instanceof conditional never matched and the container
failed to compile.
2026-07-07 13:30:57 +02:00
haylan 406da07397 chore(todod): proposal plane for sotaged the history 2026-07-02 07:30:01 +02:00
haylan d5d948f423 Merge pull request 'chore(deps): update dependency eightpoints/guzzle-bundle to v8.7.0' (#11) from renovate/eightpoints-guzzle-bundle-8.x-lockfile into main
Reviewed-on: #11
2026-06-23 17:14:32 +00:00
renovate-bot a6e904fc61 chore(deps): update dependency eightpoints/guzzle-bundle to v8.7.0 2026-06-23 12:01:52 +00:00
51 changed files with 1848 additions and 1724 deletions
+4
View File
@@ -19,3 +19,7 @@ GITLAB_URL=
GITEA_USER= GITEA_USER=
GITEA_TOKEN= GITEA_TOKEN=
GITEA_URL= GITEA_URL=
# Number of days of contribution history to keep in var/data/contributions.db.
# 0 (or unset) keeps history forever.
CONTRIBUTIONS_RETENTION_DAYS=0
+39 -1
View File
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.1.0] - 2026-05-30
### Added ### 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`) - Docker multi-stage build (`base → deps → build → final`)
- Basic `/health` endpoint - 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.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 [0.0.1]: https://github.com/ArthurErlich/git-contribution-graph/releases/tag/0.0.1
+21 -9
View File
@@ -99,6 +99,9 @@ vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Run tests matching a filter # Run tests matching a filter
vendor/bin/phpunit --filter it_renders vendor/bin/phpunit --filter it_renders
# Static analysis (PHPStan level 8)
composer phpstan
``` ```
On Windows, run via WSL (If Docker Desctop is not): On Windows, run via WSL (If Docker Desctop is not):
@@ -190,20 +193,24 @@ There is no `composer.lock` in the repo. If you add or change dependencies, run
## Architecture ## Architecture
Single-controller Symfony app with no database. The request flow: Single-controller Symfony app. Two-tier cache: a 1h filesystem SVG cache in front of a SQLite raw-data store, in front of the provider APIs. The request flow:
``` ```
GET /graph.svg?theme=dark|light GET /graph.svg?theme=dark|light
└─ GraphController └─ GraphController
├─ host check (ALLOWED_HOSTS env, optional) ├─ host check (ALLOWED_HOSTS env, optional)
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}") ├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
│ └─ on miss: │ └─ on miss: ContributionAggregator::aggregate()
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar query) │ ├─ per configured provider: ContributionStore::latestDate($name)
├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page) │ → $since = latest - 3 days (trailing overlap), or null on first run
─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap ─ GitHubProvider → GitHub GraphQL API (contributionCalendar query, bounded by $since/$until)
each returns array<string, int> (Y-m-d => count) ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page, `after`/`before` bounded)
failures are caught and logged; remaining providers still render ├─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap (filtered client-side by $since/$until)
└─ merge by date (sum counts across providers) │ each returns array<string, int> (Y-m-d => count)
│ │ failures are caught and logged; remaining providers still render
│ ├─ ContributionStore::merge() persists fresh data per provider (SQLite upsert)
│ ├─ ContributionStore::all(sinceDays: 371) reads back the render window (source of truth)
│ ├─ ContributionStore::prune() drops rows older than CONTRIBUTIONS_RETENTION_DAYS
│ └─ SvgRenderer::render() │ └─ SvgRenderer::render()
└─ Response: image/svg+xml, Cache-Control: public max-age=3600 └─ Response: image/svg+xml, Cache-Control: public max-age=3600
``` ```
@@ -212,7 +219,11 @@ GET /graph.svg?theme=dark|light
**SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 contributions → level 0, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's exact colour tokens. No external assets — the SVG is fully self-contained. **SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 contributions → level 0, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's exact colour tokens. No external assets — the SVG is fully self-contained.
**Cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently. **SVG cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently.
**ContributionStore:** PDO SQLite at `var/data/contributions.db` (also a Docker volume), table `contributions (provider, date unixtime, count)`. Keyed by `(provider, date)`, upserted via `INSERT ... ON CONFLICT`. Bounds every re-fetch to a 3-day trailing window off the last stored date per provider — the root fix for the full-365-day-refetch `MaxExecutionTimeError` that used to hit on every cache miss. `CONTRIBUTIONS_RETENTION_DAYS` controls how far back rows are kept (empty = forever).
**`graph:contributions:refetch` command:** manual escape hatch (`src/Command/RefetchContributionsCommand.php`) that bypasses the incremental trailing-window fetch and re-pulls a full or explicit date range. Options: `--provider=github,gitlab` (comma-separated, default all configured), `--from=YYYY-MM-DD` (default 365 days ago), `--to=YYYY-MM-DD` (default today), `--all` (shorthand for `--from=2005-01-01`). Splits the requested range into ≤365-day chunks (GitHub's GraphQL window limit; also caps GitLab pagination per call) and merges each chunk into the store as it completes, so a large `--all` run can't reintroduce the original runaway-pagination timeout.
## Environment variables ## Environment variables
@@ -224,5 +235,6 @@ GET /graph.svg?theme=dark|light
| `GITLAB_URL` | No | Defaults to `https://gitlab.com` | | `GITLAB_URL` | No | Defaults to `https://gitlab.com` |
| `GITEA_USER` / `GITEA_TOKEN` / `GITEA_URL` | For Gitea | Token scope: `read:user` | | `GITEA_USER` / `GITEA_TOKEN` / `GITEA_URL` | For Gitea | Token scope: `read:user` |
| `ALLOWED_HOSTS` | No | Comma-separated hostnames; empty = allow all | | `ALLOWED_HOSTS` | No | Comma-separated hostnames; empty = allow all |
| `CONTRIBUTIONS_RETENTION_DAYS` | No | Days of history to keep in the SQLite store; empty = keep forever |
Copy `.env` to `.env.local` for local development — `.env.local` is gitignored. Copy `.env` to `.env.local` for local development — `.env.local` is gitignored.
+1 -1
View File
@@ -49,7 +49,7 @@ COPY --link docker/frankenphp/Caddyfile /etc/caddy/Caddyfile
COPY --link docker/php/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/ COPY --link docker/php/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
RUN chmod +x bin/console && \ RUN chmod +x bin/console && \
mkdir -p var/cache/prod/pools var/log /config/caddy /data/caddy && \ mkdir -p var/cache/prod/pools var/log var/data /config/caddy /data/caddy && \
chown -R app:app /app /config /data chown -R app:app /app /config /data
USER app USER app
+1 -1
View File
@@ -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 \ RUN apk add --no-cache icu-dev libzip-dev \
&& docker-php-ext-install -j$(nproc) intl opcache zip \ && docker-php-ext-install -j$(nproc) intl opcache zip \
+72 -16
View File
@@ -40,10 +40,14 @@ services:
env_file: env_file:
- .env.local - .env.local
volumes: volumes:
- cache:/var/www/html/var/cache - cache:/app/var/cache/prod/pools
- logs:/app/var/log
- data:/app/var/data
volumes: volumes:
cache: cache:
logs:
data:
``` ```
The image is published to the Gitea container registry. Pull it manually with: The image is published to the Gitea container registry. Pull it manually with:
@@ -77,6 +81,9 @@ GITEA_URL=https://git.example.com
# Optional: restrict to specific hostnames (comma-separated), leave empty to allow all # Optional: restrict to specific hostnames (comma-separated), leave empty to allow all
ALLOWED_HOSTS= ALLOWED_HOSTS=
# Optional: days of contribution history to keep in the SQLite store, empty/0 = keep forever
CONTRIBUTIONS_RETENTION_DAYS=
``` ```
Only configure the platforms you use — unused ones are silently skipped. Only configure the platforms you use — unused ones are silently skipped.
@@ -87,15 +94,26 @@ Only configure the platforms you use — unused ones are silently skipped.
docker compose up -d docker compose up -d
``` ```
The service listens on **port 8080** by default. Put Traefik or nginx in front of it for HTTPS. The service listens on **port 8080** by default (served by FrankenPHP/Caddy, baked into the image — no separate PHP-FPM/nginx needed). Put Traefik or nginx in front of it only if you need HTTPS termination.
### 4. Verify ### 4. Verify
```bash ```bash
curl http://localhost:8080/health curl http://localhost:8080/health
# {"status":"ok"}
``` ```
```json
{
"status": "ok",
"providers": {
"github": { "status": "ok" },
"gitlab": { "status": "ok" }
}
}
```
Returns HTTP 503 with `"status": "degraded"` if any configured provider's probe fails (its entry then includes `error` and `message`).
--- ---
## API ## API
@@ -175,7 +193,7 @@ docker compose up -d --build
docker compose exec graph sh docker compose exec graph sh
# Run tests inside the container # 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 # Disable Xdebug for faster test runs
XDEBUG_MODE=off docker compose up -d XDEBUG_MODE=off docker compose up -d
@@ -195,34 +213,49 @@ docker compose -f docker-compose.yml up -d --build
```bash ```bash
# Run full suite # Run full suite
php bin/phpunit vendor/bin/phpunit
# Human-readable output # Human-readable output
php bin/phpunit --testdox vendor/bin/phpunit --testdox
# Single file # Single file
php bin/phpunit tests/Unit/Service/SvgRendererTest.php vendor/bin/phpunit tests/Unit/Service/Renderer/SvgRendererTest.php
# Filter by name # 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 ## Architecture
Two-tier cache: a 1h filesystem SVG cache in front of a SQLite raw-data store, in front of the provider APIs.
``` ```
GET /graph.svg?theme=dark|light GET /graph.svg?theme=dark|light
└─ GraphController └─ GraphController
├─ host check (ALLOWED_HOSTS env, optional) ├─ host check (ALLOWED_HOSTS env, optional)
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}") ├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
│ └─ on miss: │ └─ on miss: ContributionAggregator::aggregate()
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar) │ ├─ per configured provider: ContributionStore::latestDate($name)
├─ GitLabProvider → GitLab REST API (/users/:id/events, paginated) │ → $since = latest - 3 days (trailing overlap), or null on first run
─ GiteaProvider → Gitea REST API (/users/:user/heatmap) ─ GitHubProvider → GitHub GraphQL API (contributionCalendar query, bounded by $since/$until)
each returns array<string, int> (Y-m-d => count) ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page, `after`/`before` bounded)
failures are caught and logged; remaining providers still render ├─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap (filtered client-side by $since/$until)
└─ merge by date (sum counts across providers) │ each returns array<string, int> (Y-m-d => count); providers fetch concurrently
│ │ (start/resolve split); failures are caught and logged, remaining providers still render
│ ├─ ContributionStore::merge() persists fresh data per provider (SQLite upsert)
│ ├─ ContributionStore::all(sinceDays: 371) reads back the render window (source of truth)
│ ├─ ContributionStore::prune() drops rows older than CONTRIBUTIONS_RETENTION_DAYS
│ └─ SvgRenderer::render() │ └─ SvgRenderer::render()
└─ Response: image/svg+xml, Cache-Control: public max-age=3600 └─ Response: image/svg+xml, Cache-Control: public max-age=3600
``` ```
@@ -231,7 +264,30 @@ GET /graph.svg?theme=dark|light
**SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 → level 0, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained. **SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 → level 0, 13 → 1, 46 → 2, 79 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained.
**Cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently. **SVG cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently.
**ContributionStore:** PDO SQLite at `var/data/contributions.db` (also a Docker volume), keyed by `(provider, date)`. Bounds every re-fetch to a trailing window off the last stored date per provider, instead of re-pulling all 365 days on every cache miss. `CONTRIBUTIONS_RETENTION_DAYS` controls how far back rows are kept (empty/0 = forever).
**Health check:** `GET /health` probes each configured provider's credentials/reachability (without fetching contribution data) and reports `ok`/`degraded`.
---
## CLI
`graph:contributions:refetch` is a manual escape hatch that bypasses the incremental trailing-window fetch and re-pulls a full or explicit date range — useful for backfilling history or recovering from a gap.
```bash
docker compose exec graph bin/console graph:contributions:refetch --all
```
| Option | Default | Description |
| ------------ | -------------------- | --------------------------------------------------------- |
| `--provider` | all configured | Comma-separated provider names to refetch, e.g. `github,gitlab` |
| `--from` | 365 days ago | Start date (`YYYY-MM-DD`) |
| `--to` | today | End date (`YYYY-MM-DD`) |
| `--all` | off | Shorthand for `--from=2005-01-01` |
Large ranges are split into ≤365-day chunks and merged into the store as each completes.
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
use App\Kernel; use GitContributionGraph\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
+6 -4
View File
@@ -5,8 +5,6 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"eightpoints/guzzle-bundle": "^8.6",
"idci/graphql-client-bundle": "^2.0",
"monolog/monolog": "^3.10", "monolog/monolog": "^3.10",
"symfony/cache": "7.4.*", "symfony/cache": "7.4.*",
"symfony/console": "7.4.*", "symfony/console": "7.4.*",
@@ -18,15 +16,16 @@
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"App\\": "src/" "GitContributionGraph\\": "src/"
} }
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"App\\Tests\\": "tests/" "GitContributionGraph\\Tests\\": "tests/"
} }
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^11.5", "phpunit/phpunit": "^11.5",
"symfony/phpunit-bridge": "^7.4" "symfony/phpunit-bridge": "^7.4"
}, },
@@ -42,5 +41,8 @@
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "7.4.*"
} }
},
"scripts": {
"phpstan": "phpstan analyse --memory-limit=512M"
} }
} }
Generated
+372 -1413
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -3,6 +3,4 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true],
IDCI\Bundle\GraphQLClientBundle\IDCIGraphQLClientBundle::class => ['all' => true],
]; ];
-7
View File
@@ -1,7 +0,0 @@
eight_points_guzzle:
clients:
github_graphql:
base_url: 'https://api.github.com/graphql'
options:
headers:
Authorization: 'Bearer %env(GITHUB_TOKEN)%'
+4
View File
@@ -7,3 +7,7 @@ framework:
cache: cache:
app: cache.adapter.filesystem app: cache.adapter.filesystem
default_redis_provider: 'redis://localhost' 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
-4
View File
@@ -1,4 +0,0 @@
idci_graphql_client:
clients:
github:
http_client: 'eight_points_guzzle.client.github_graphql'
+3 -59
View File
@@ -37,7 +37,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed> * @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key * @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // 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 DeprecationType = array{package: string, version: string, message?: string}
* @psalm-type DefaultsType = array{ * @psalm-type DefaultsType = array{
* public?: bool, * public?: bool,
@@ -121,7 +121,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* } * }
* @psalm-type ServicesConfig = array{ * @psalm-type ServicesConfig = array{
* _defaults?: DefaultsType, * _defaults?: DefaultsType,
* _instanceof?: InstanceofType, * _instanceof?: array<class-string, InstanceofType>,
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null> * ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
* } * }
* @psalm-type ExtensionType = array<string, mixed> * @psalm-type ExtensionType = array<string, mixed>
@@ -302,7 +302,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* }, * },
* translator?: bool|array{ // Translator configuration * translator?: bool|array{ // Translator configuration
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: false
* fallbacks?: string|list<scalar|Param|null>, * fallbacks?: string|list<scalar|Param|null>,
* logging?: bool|Param, // Default: false * logging?: bool|Param, // Default: false
* formatter?: scalar|Param|null, // Default: "translator.formatter.default" * 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<string, array{ // Default: []
* class?: scalar|Param|null, // Default: "%eight_points_guzzle.http_client.class%"
* base_url?: scalar|Param|null, // Default: null
* lazy?: bool|Param, // Default: false
* logging?: int|Param, // Default: null
* handler?: scalar|Param|null, // Default: null
* options?: array{
* headers?: array<string, scalar|Param|null>,
* allow_redirects?: mixed,
* auth?: mixed,
* query?: mixed,
* curl?: list<scalar|Param|null>,
* cert?: mixed,
* connect_timeout?: scalar|Param|null,
* debug?: bool|Param,
* decode_content?: mixed,
* delay?: float|Param,
* form_params?: array<string, mixed>,
* multipart?: list<mixed>,
* 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<scalar|Param|null>,
* },
* version?: scalar|Param|null,
* },
* plugin?: array<mixed>,
* }>,
* 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<array{ // Default: []
* http_client?: scalar|Param|null,
* cache?: scalar|Param|null,
* }>,
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -906,8 +852,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
+1 -1
View File
@@ -1,5 +1,5 @@
controllers: controllers:
resource: resource:
path: ../src/Controller/ path: ../src/Controller/
namespace: App\Controller namespace: GitContributionGraph\Controller
type: attribute type: attribute
+31 -26
View File
@@ -1,14 +1,15 @@
parameters: parameters:
env(APP_SECRET): '' env(APP_SECRET): ""
env(ALLOWED_HOSTS): '' env(ALLOWED_HOSTS): ""
env(GITHUB_USER): '' env(GITHUB_USER): ""
env(GITHUB_TOKEN): '' env(GITHUB_TOKEN): ""
env(GITLAB_USER): '' env(GITLAB_USER): ""
env(GITLAB_TOKEN): '' env(GITLAB_TOKEN): ""
env(GITLAB_URL): '' env(GITLAB_URL): ""
env(GITEA_USER): '' env(GITEA_USER): ""
env(GITEA_TOKEN): '' env(GITEA_TOKEN): ""
env(GITEA_URL): '' env(GITEA_URL): ""
env(CONTRIBUTIONS_RETENTION_DAYS): "0"
services: services:
_defaults: _defaults:
@@ -16,28 +17,32 @@ services:
autoconfigure: true autoconfigure: true
public: false public: false
App\: GitContributionGraph\:
resource: '../src/' resource: "../src/"
exclude: exclude:
- '../src/Kernel.php' - "../src/Kernel.php"
_instanceof: _instanceof:
App\Service\ProviderInterface: GitContributionGraph\Service\Provider\ProviderInterface:
tags: ['app.provider'] tags: ["app.provider"]
App\Service\GitHubProvider: GitContributionGraph\Service\Provider\GitHubProvider:
arguments: arguments:
$username: '%env(GITHUB_USER)%' $username: "%env(GITHUB_USER)%"
$token: '%env(GITHUB_TOKEN)%' $token: "%env(GITHUB_TOKEN)%"
App\Service\GitLabProvider: GitContributionGraph\Service\Provider\GitLabProvider:
arguments: arguments:
$username: '%env(GITLAB_USER)%' $username: "%env(GITLAB_USER)%"
$token: '%env(GITLAB_TOKEN)%' $token: "%env(GITLAB_TOKEN)%"
$baseUrl: '%env(GITLAB_URL)%' $baseUrl: "%env(GITLAB_URL)%"
App\Service\GiteaProvider: GitContributionGraph\Service\Provider\GiteaProvider:
arguments: arguments:
$username: '%env(GITEA_USER)%' $username: "%env(GITEA_USER)%"
$token: '%env(GITEA_TOKEN)%' $token: "%env(GITEA_TOKEN)%"
$baseUrl: '%env(GITEA_URL)%' $baseUrl: "%env(GITEA_URL)%"
GitContributionGraph\Service\ContributionStore:
arguments:
$dbPath: "%kernel.project_dir%/var/data/contributions.db"
$retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%"
+3 -1
View File
@@ -4,9 +4,11 @@ services:
graph: graph:
build: build:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
target: dev
volumes: volumes:
- .:/app - .:/app
- /app/vendor # keeps vendor from the dev image, not your local dir - ./vendor:/app/vendor
- /app/var/data
environment: environment:
APP_ENV: dev APP_ENV: dev
APP_DEBUG: "1" APP_DEBUG: "1"
+3
View File
@@ -18,9 +18,11 @@ services:
GITEA_USER: "${GITEA_USER:-}" GITEA_USER: "${GITEA_USER:-}"
GITEA_TOKEN: "${GITEA_TOKEN:-}" GITEA_TOKEN: "${GITEA_TOKEN:-}"
GITEA_URL: "${GITEA_URL:-}" GITEA_URL: "${GITEA_URL:-}"
CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}"
volumes: volumes:
- cache:/app/var/cache/prod/pools - cache:/app/var/cache/prod/pools
- logs:/app/var/log - logs:/app/var/log
- data:/app/var/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
@@ -30,3 +32,4 @@ services:
volumes: volumes:
cache: cache:
logs: logs:
data:
+3
View File
@@ -20,9 +20,11 @@ services:
GITEA_USER: "${GITEA_USER:-}" GITEA_USER: "${GITEA_USER:-}"
GITEA_TOKEN: "${GITEA_TOKEN:-}" GITEA_TOKEN: "${GITEA_TOKEN:-}"
GITEA_URL: "${GITEA_URL:-}" GITEA_URL: "${GITEA_URL:-}"
CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}"
volumes: volumes:
- cache:/app/var/cache/prod/pools - cache:/app/var/cache/prod/pools
- logs:/app/var/log - logs:/app/var/log
- data:/app/var/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
@@ -33,3 +35,4 @@ services:
volumes: volumes:
cache: cache:
logs: logs:
data:
+5
View File
@@ -0,0 +1,5 @@
parameters:
level: 8
paths:
- src
- tests
+1 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
use App\Kernel; use GitContributionGraph\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
+1 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Kernel; use GitContributionGraph\Kernel;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload.php'; require_once dirname(__DIR__).'/vendor/autoload.php';
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Command;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
#[AsCommand(
name: 'graph:contributions:refetch',
description: 'Force a full or ranged re-fetch of contribution history into the store.',
)]
final class RefetchContributionsCommand extends Command
{
private const MAX_CHUNK_DAYS = 365;
private const ALL_SINCE = '2005-01-01';
private SymfonyStyle $io;
/** @param iterable<ProviderInterface> $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<array{0: \DateTimeImmutable, 1: \DateTimeImmutable}> $chunks
*
* @return array{0: int, 1: int} days written, errors
*/
private function refetchProvider(string $name, ProviderInterface $provider, array $chunks): array
{
$this->io->section("Refetching {$name}");
$progress = new ProgressBar($this->io, count($chunks));
$progress->start();
$daysWritten = 0;
$errors = 0;
foreach ($chunks as [$chunkFrom, $chunkTo]) {
try {
$fresh = $provider->resolveFetch($provider->startFetch($chunkFrom, $chunkTo));
$dateCounts = [];
foreach ($fresh as $date => $count) {
$dateCounts[(new \DateTimeImmutable($date))->getTimestamp()] = $count;
}
$this->store->merge($name, $dateCounts);
$daysWritten += count($dateCounts);
} catch (\Throwable $e) {
$errors++;
$this->io->warning("{$name}: {$e->getMessage()}");
}
$progress->advance();
}
$progress->finish();
$this->io->newLine(2);
return [$daysWritten, $errors];
}
/**
* @return array<string, ProviderInterface>|null null when an unknown provider name was requested
*/
private function resolveProviders(string $requested): ?array
{
$byName = [];
foreach ($this->providers as $providerInstance) {
$byName[$providerInstance->getName()] = $providerInstance;
}
if ($requested === '') {
return array_filter($byName, static fn (ProviderInterface $p): bool => $p->isConfigured());
}
$names = explode(',', $requested);
foreach ($names as $name) {
if (!isset($byName[$name])) {
$this->io->error("Unknown provider: {$name}");
return null;
}
}
return array_intersect_key($byName, array_flip($names));
}
/**
* @return list<array{0: \DateTimeImmutable, 1: \DateTimeImmutable}>
*/
private function chunk(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$chunks = [];
$cursor = $from;
while ($cursor < $to) {
$chunkEnd = min($cursor->modify('+' . (self::MAX_CHUNK_DAYS - 1) . ' days'), $to);
$chunks[] = [$cursor, $chunkEnd];
$cursor = $chunkEnd->modify('+1 day');
}
if ($chunks === []) {
$chunks[] = [$from, $to];
}
return $chunks;
}
}
+23 -6
View File
@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller; namespace GitContributionGraph\Controller;
use App\Service\ContributionAggregator; use GitContributionGraph\Service\ContributionAggregator;
use App\Service\Provider\ProviderHealthChecker; use GitContributionGraph\Service\Provider\ProviderHealthChecker;
use App\Service\Renderer\SvgRenderer; use GitContributionGraph\Service\Renderer\SvgRenderer;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -34,8 +34,12 @@ final class GraphController
} }
/** /**
* Query parameters: * Serves the contribution heatmap SVG, from a 1-hour cache on repeat requests.
* theme string dark|light (default: dark) *
* Rejects requests to disallowed hosts (ALLOWED_HOSTS env) with a 403.
*
* @param Request $request query parameter `theme` selects "dark" (default) or "light"
* @return Response image/svg+xml body, cacheable for 3600s
*/ */
#[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])] #[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])]
public function graph(Request $request): Response public function graph(Request $request): Response
@@ -53,6 +57,7 @@ final class GraphController
$svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string { $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string {
$cacheMiss = true; $cacheMiss = true;
$item->expiresAfter(3600); $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); 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'])] #[Route('/', name: 'index', methods: ['GET'])]
public function index(Request $request): RedirectResponse public function index(Request $request): RedirectResponse
{ {
@@ -74,6 +85,12 @@ final class GraphController
return new RedirectResponse($url, 302); return new RedirectResponse($url, 302);
} }
/**
* Reports per-provider connectivity status as JSON.
*
* @return Response 200 with {"status":"ok",...} or 503 with {"status":"degraded",...}
* when any configured provider's probe reports an error
*/
#[Route('/health', name: 'health', methods: ['GET'])] #[Route('/health', name: 'health', methods: ['GET'])]
public function health(): Response public function health(): Response
{ {
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Entity;
/** A single provider's contribution count for one day, as stored in {@see \GitContributionGraph\Service\ContributionStore}. */
final class Contribution
{
/**
* @param string $provider provider identifier, e.g. "github"
* @param int $date day of the contribution, as a unix timestamp
* @param int $count contribution count for that day
*/
public function __construct(
public readonly string $provider,
public readonly int $date,
public readonly int $count,
) {
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Entity;
/**
* @implements \IteratorAggregate<int, Contribution>
*/
final class ContributionCollection implements \IteratorAggregate, \Countable
{
/** @var array<int, Contribution> */
private readonly array $contributions;
public function __construct(Contribution ...$contributions)
{
$this->contributions = array_values($contributions);
}
/** @return \ArrayIterator<int, Contribution> */
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->contributions);
}
/** Number of contributions held in this collection. */
public function count(): int
{
return count($this->contributions);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
namespace App; namespace GitContributionGraph;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\Component\HttpKernel\Kernel as BaseKernel;
+57 -9
View File
@@ -2,40 +2,88 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service; namespace GitContributionGraph\Service;
use App\Service\Provider\ProviderInterface; use GitContributionGraph\Service\Provider\ProviderInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ContributionAggregator final class ContributionAggregator
{ {
private const RENDER_WINDOW_DAYS = 371;
private const OVERLAP_DAYS = 3;
/** @param iterable<ProviderInterface> $providers */
public function __construct( public function __construct(
#[AutowireIterator('app.provider')] #[AutowireIterator('app.provider')]
private readonly iterable $providers, private readonly iterable $providers,
private readonly ContributionStore $store,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) {} ) {}
/** @return array<string, int> */ /**
* Incrementally fetches fresh contributions from each configured provider, merges them into
* the store, and returns the rolling render window summed across providers.
*
* For each provider, only data since its last stored date (minus a trailing overlap) is
* fetched; a provider with no stored history yet fetches its full default range. Fetches are
* split into start/resolve phases so all providers' requests are in flight concurrently.
* Failures are logged and the affected provider is skipped, not fatal to the others.
*
* @return array<string, int> date (Y-m-d) => contribution count, summed across all providers
*/
public function aggregate(): array public function aggregate(): array
{ {
$contributions = []; $configured = [];
/** @var ProviderInterface $provider */ /** @var ProviderInterface $provider */
foreach ($this->providers as $provider) { foreach ($this->providers as $provider) {
if (!$provider->isConfigured()) { if ($provider->isConfigured()) {
continue; $configured[] = $provider;
} }
}
$pending = [];
foreach ($configured as $provider) {
$latest = $this->store->latestDate($provider->getName());
$since = $latest !== null
? (new \DateTimeImmutable('@' . $latest))->modify('-' . self::OVERLAP_DAYS . ' days')
: null;
try { try {
foreach ($provider->fetch() as $date => $count) { $pending[] = [$provider, $provider->startFetch($since)];
$contributions[$date] = ($contributions[$date] ?? 0) + $count;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]); $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
} }
} }
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; return $contributions;
} }
} }
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service;
use GitContributionGraph\Entity\Contribution;
use GitContributionGraph\Entity\ContributionCollection;
use PDO;
class ContributionStore
{
private PDO $pdo;
private const int DAY_IN_SECONDS = 86400;
/**
* Opens (creating if needed) the SQLite store at $dbPath and ensures the schema exists.
*
* @param string $dbPath filesystem path to the SQLite database file
* @param ?int $retentionDays days of history to keep; null or 0 keeps rows forever
*/
public function __construct(
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db",
private readonly ?int $retentionDays = null,
) {
$dir = \dirname($this->dbPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, recursive: true);
}
$this->pdo = new PDO('sqlite:' . $this->dbPath);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->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<int, int> $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<int, array{provider: string, date: int|string, count: int|string}> $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]);
}
}
+27 -30
View File
@@ -2,13 +2,12 @@
declare(strict_types=1); 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 Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* Fetches the last 365 days of contributions from the GitHub GraphQL API. * Fetches the last 365 days of contributions from the GitHub GraphQL API.
@@ -23,7 +22,6 @@ final class GitHubProvider implements ProviderInterface
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly GraphQLApiClientRegistryInterface $registry,
private readonly string $username, private readonly string $username,
private readonly string $token, private readonly string $token,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
@@ -46,44 +44,43 @@ final class GitHubProvider implements ProviderInterface
])->getContent(); ])->getContent();
} }
/** /** Fires the GraphQL contributionsCollection query bounded by $since/$until. */
* @return array<string, int> date (Y-m-d) => contribution count public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): ResponseInterface
*/
public function fetch(): array
{ {
$this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]); $this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]);
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); $from = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); $to = ($until ?? new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
/** @var GraphQLApiClient $graphqlClient */ $query = sprintf(
$graphqlClient = $this->registry->get('github'); '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( // GitHub's GraphQL API requires application/json.
['user' => ['login' => $this->username]], // request() doesn't block; the response is read in resolveFetch() so
[ // multiple providers' requests can be in flight at once.
'contributionsCollection' => [ return $this->client->request('POST', self::GRAPHQL_URL, [
'_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, [
'headers' => [ 'headers' => [
'Authorization' => "Bearer {$this->token}", 'Authorization' => "Bearer {$this->token}",
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
], ],
'json' => ['query' => $query], '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<string, int> date (Y-m-d) => contribution count
*/
public function resolveFetch(mixed $handle): array
{
/** @var ResponseInterface $handle */
$data = $handle->toArray();
if (isset($data['errors'])) { if (isset($data['errors'])) {
throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors'])); throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors']));
+59 -23
View File
@@ -2,11 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* Fetches the last 365 days of push/merge events from the GitLab REST API. * 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; use ProbeTrait;
private readonly string $baseUrl;
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly string $username, private readonly string $username,
private readonly string $token, private readonly string $token,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly string $baseUrl = '', string $baseUrl = '',
) {} ) {
$this->baseUrl = rtrim($baseUrl !== '' ? $baseUrl : 'https://gitlab.com', '/');
}
public function getName(): string public function getName(): string
{ {
@@ -38,47 +43,66 @@ final class GitLabProvider implements ProviderInterface
public function ping(): void public function ping(): void
{ {
$baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/');
$this->client->request('GET', "$this->baseUrl/api/v4/user", [
$this->client->request('GET', "$baseUrl/api/v4/user", [
'headers' => ['PRIVATE-TOKEN' => $this->token], 'headers' => ['PRIVATE-TOKEN' => $this->token],
])->getContent(); ])->getContent();
} }
/** /**
* @return array<string, int> 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], 'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => ['username' => $this->username], 'query' => ['username' => $this->username],
]); ]);
$users = $userResponse->toArray(); $users = $userResponse->toArray();
if (empty($users)) { 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']; $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<string, int> 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 = []; $result = [];
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
$page = 1;
do { 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(); $events = $response->toArray();
foreach ($events as $event) { foreach ($events as $event) {
@@ -87,6 +111,18 @@ final class GitLabProvider implements ProviderInterface
} }
$page++; $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); } while (count($events) === 100);
$this->logger->info('GitLabProvider: fetched contributions', [ $this->logger->info('GitLabProvider: fetched contributions', [
+37 -14
View File
@@ -2,10 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* Fetches contribution data from the Gitea heatmap endpoint. * Fetches contribution data from the Gitea heatmap endpoint.
@@ -19,13 +20,17 @@ final class GiteaProvider implements ProviderInterface
{ {
use ProbeTrait; use ProbeTrait;
private readonly string $baseUrl;
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly string $username, private readonly string $username,
private readonly string $token, private readonly string $token,
private readonly string $baseUrl, string $baseUrl,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) {} ) {
$this->baseUrl = rtrim($baseUrl, '/');
}
public function getName(): string public function getName(): string
{ {
@@ -39,34 +44,52 @@ final class GiteaProvider implements ProviderInterface
public function ping(): void 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}"], 'headers' => ['Authorization' => "token {$this->token}"],
])->getContent(); ])->getContent();
} }
/** /**
* @return array<string, int> 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', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [
$response = $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [
'headers' => ['Authorization' => "token {$this->token}"], 'headers' => ['Authorization' => "token {$this->token}"],
]); ]);
$data = $response->toArray(); return ['response' => $response, 'since' => $since, 'until' => $until];
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp(); }
$result = [];
/**
* Filters the heatmap entries to the [$since, $until] window and sums contributions per day.
*
* @param array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable} $handle
* @return array<string, int> date (Y-m-d) => contribution count
*/
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) { foreach ($data as $entry) {
if ($entry['timestamp'] < $cutoff) { if ($entry['timestamp'] < $cutoff) {
continue; continue;
} }
if ($ceiling !== null && $entry['timestamp'] > $ceiling) {
continue;
}
$date = date('Y-m-d', $entry['timestamp']); $date = date('Y-m-d', $entry['timestamp']);
$result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions']; $result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions'];
} }
+5 -1
View File
@@ -2,13 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
trait ProbeTrait trait ProbeTrait
{ {
/**
* Reports NotConfigured if credentials are missing, otherwise pings the provider
* and reports Ok or Error (with a classified error code and message).
*/
public function probe(): ProviderStatus public function probe(): ProviderStatus
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
+1 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
enum ProviderErrorCode: string enum ProviderErrorCode: string
{ {
@@ -2,19 +2,23 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ProviderHealthChecker final class ProviderHealthChecker
{ {
/** @param iterable<ProviderInterface> $providers */
public function __construct( public function __construct(
#[AutowireIterator('app.provider')] #[AutowireIterator('app.provider')]
private readonly iterable $providers, private readonly iterable $providers,
) {} ) {}
/** /**
* @return array{status: string, providers: array<string, array<string, string>>} * Probes every configured provider and aggregates the results for the /health endpoint.
*
* @return array{status: string, providers: array<string, array<string, string>>} 'status' is
* "degraded" if any provider's probe() reported an error, otherwise "ok"
*/ */
public function check(): array public function check(): array
{ {
+22 -3
View File
@@ -2,18 +2,37 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
interface ProviderInterface interface ProviderInterface
{ {
/** @return array<string, int> 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<string, int> 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; public function isConfigured(): bool;
/** Short lowercase identifier for this provider, e.g. "github". */
public function getName(): string; public function getName(): string;
/** Check reachability/credentials without fetching contribution data; used by the /health endpoint. */
public function probe(): ProviderStatus; public function probe(): ProviderStatus;
/** Make a lightweight authenticated request to verify the provider is reachable; throws on failure. */
public function ping(): void; public function ping(): void;
} }
+13 -2
View File
@@ -2,10 +2,17 @@
declare(strict_types=1); 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 final class ProviderStatus
{ {
/**
* @param string $name provider identifier, e.g. "github"
* @param ProviderStatusType $status overall outcome of the probe
* @param ?ProviderErrorCode $error classified error code, set only when $status is Error
* @param ?string $message human-readable error detail, set only when $status is Error
*/
public function __construct( public function __construct(
public readonly string $name, public readonly string $name,
public readonly ProviderStatusType $status, public readonly ProviderStatusType $status,
@@ -13,7 +20,11 @@ final class ProviderStatus
public readonly ?string $message = null, public readonly ?string $message = null,
) {} ) {}
/** @return array<string, string> */ /**
* Converts to the array shape used in the /health JSON response, omitting error/message when unset.
*
* @return array<string, string>
*/
public function toArray(): array public function toArray(): array
{ {
$data = ['status' => $this->status->value]; $data = ['status' => $this->status->value];
+1 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Provider; namespace GitContributionGraph\Service\Provider;
enum ProviderStatusType: string enum ProviderStatusType: string
{ {
+22 -3
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Service\Renderer; namespace GitContributionGraph\Service\Renderer;
/** /**
* Renders a GitHub-style contribution heatmap as an inline SVG. * 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 MARGIN_Y = 20; // top margin for month labels
private const PADDING = 10; // outer padding private const PADDING = 10; // outer padding
/**
* Builds the full self-contained SVG heatmap for a contribution map.
*
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @param string $theme "dark" or "light"; unknown values fall back to "dark"
*/
public function render(array $contributions, string $theme = 'dark'): string public function render(array $contributions, string $theme = 'dark'): string
{ {
$colors = self::THEMES[$theme] ?? self::THEMES['dark']; $colors = self::THEMES[$theme] ?? self::THEMES['dark'];
@@ -91,6 +97,9 @@ final class SvgRenderer
/** /**
* Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int] * Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int]
* or null if the date is in the future. * or null if the date is in the future.
*
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @return array<int, array<int, array{date: string, count: int}|null>>
*/ */
private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array
{ {
@@ -125,6 +134,10 @@ final class SvgRenderer
}; };
} }
/**
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @return array{total: int}
*/
private function computeStats(array $contributions): array private function computeStats(array $contributions): array
{ {
return ['total' => array_sum($contributions)]; return ['total' => array_sum($contributions)];
@@ -132,6 +145,7 @@ final class SvgRenderer
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** @param array<int, array<int, array{date: string, count: int}|null>> $grid */
private function renderMonthLabels(array $grid, string $textColor): string private function renderMonthLabels(array $grid, string $textColor): string
{ {
$out = ''; $out = '';
@@ -142,7 +156,7 @@ final class SvgRenderer
if ($cell === null) { if ($cell === null) {
continue; continue;
} }
$ts = strtotime($cell['date']); $ts = (int) strtotime($cell['date']);
$month = (int) date('n', $ts); $month = (int) date('n', $ts);
$dom = (int) date('j', $ts); $dom = (int) date('j', $ts);
@@ -183,6 +197,10 @@ final class SvgRenderer
return $out; return $out;
} }
/**
* @param array<int, array<int, array{date: string, count: int}|null>> $grid
* @param array{bg: string, text: string, levels: array<int, string>} $colors
*/
private function renderCells(array $grid, array $colors): string private function renderCells(array $grid, array $colors): string
{ {
$out = ''; $out = '';
@@ -197,7 +215,7 @@ final class SvgRenderer
$y = self::MARGIN_Y + $d * self::STEP; $y = self::MARGIN_Y + $d * self::STEP;
$color = $colors['levels'][$this->level($cell['count'])]; $color = $colors['levels'][$this->level($cell['count'])];
$ts = strtotime($cell['date']); $ts = (int) strtotime($cell['date']);
$suffix = $cell['count'] !== 1 ? 's' : ''; $suffix = $cell['count'] !== 1 ? 's' : '';
$label = $cell['count'] > 0 $label = $cell['count'] > 0
? $cell['count'] . ' contribution' . $suffix . ' on ' . date('F j, Y', $ts) ? $cell['count'] . ' contribution' . $suffix . ' on ' . date('F j, Y', $ts)
@@ -218,6 +236,7 @@ final class SvgRenderer
return $out; return $out;
} }
/** @param array{bg: string, text: string, levels: array<int, string>} $colors */
private function renderLegend(int $totalH, array $colors): string private function renderLegend(int $totalH, array $colors): string
{ {
$y = $totalH - 14; $y = $totalH - 14;
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Command;
use GitContributionGraph\Command\RefetchContributionsCommand;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(RefetchContributionsCommand::class)]
final class RefetchContributionsCommandTest extends TestCase
{
/** @param ?array<string, int> $fresh */
private function makeProvider(
string $name,
bool $configured = true,
?array $fresh = null,
?\Throwable $throws = null,
): ProviderInterface {
$provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn($name);
$provider->method('isConfigured')->willReturn($configured);
if ($throws !== null) {
$provider->method('startFetch')->willThrowException($throws);
} elseif ($fresh !== null) {
$provider->method('resolveFetch')->willReturn($fresh);
}
return $provider;
}
/** @param iterable<ProviderInterface> $providers */
private function makeTester(iterable $providers, ContributionStore $store): CommandTester
{
$command = new RefetchContributionsCommand($providers, $store);
$application = new Application();
$application->add($command);
return new CommandTester($application->find('graph:contributions:refetch'));
}
#[Test]
public function it_refetches_the_default_365_day_window_for_all_configured_providers(): void
{
$store = new ContributionStore(':memory:');
$provider = $this->makeProvider('github', fresh: ['2024-06-10' => 3]);
$tester = $this->makeTester([$provider], $store);
$exitCode = $tester->execute([]);
$this->assertSame(0, $exitCode);
$this->assertCount(1, $store->all('github'));
}
#[Test]
public function it_skips_unconfigured_providers_by_default(): void
{
$store = new ContributionStore(':memory:');
$provider = $this->makeProvider('github', configured: false);
$tester = $this->makeTester([$provider], $store);
$tester->execute([]);
$this->assertCount(0, $store->all('github'));
}
#[Test]
public function it_fails_cleanly_on_an_unknown_provider_name(): void
{
$store = new ContributionStore(':memory:');
$provider = $this->makeProvider('github');
$tester = $this->makeTester([$provider], $store);
$exitCode = $tester->execute(['--provider' => 'bogus']);
$this->assertSame(1, $exitCode);
$this->assertStringContainsString('Unknown provider: bogus', $tester->getDisplay());
}
#[Test]
public function it_restricts_to_the_named_provider(): void
{
$store = new ContributionStore(':memory:');
$github = $this->makeProvider('github', fresh: ['2024-06-10' => 1]);
$gitlab = $this->makeProvider('gitlab', fresh: ['2024-06-10' => 1]);
$tester = $this->makeTester([$github, $gitlab], $store);
$tester->execute(['--provider' => 'github']);
$this->assertCount(1, $store->all('github'));
$this->assertCount(0, $store->all('gitlab'));
}
#[Test]
public function it_continues_with_remaining_providers_when_one_throws(): void
{
$store = new ContributionStore(':memory:');
$failing = $this->makeProvider('gitlab', throws: new \RuntimeException('boom'));
$healthy = $this->makeProvider('github', fresh: ['2024-06-10' => 2]);
$tester = $this->makeTester([$failing, $healthy], $store);
$exitCode = $tester->execute([]);
$this->assertSame(0, $exitCode);
$this->assertCount(1, $store->all('github'));
$this->assertCount(0, $store->all('gitlab'));
}
#[Test]
public function it_fails_when_every_provider_errors(): void
{
$store = new ContributionStore(':memory:');
$failing = $this->makeProvider('github', throws: new \RuntimeException('boom'));
$tester = $this->makeTester([$failing], $store);
$exitCode = $tester->execute([]);
$this->assertSame(1, $exitCode);
}
#[Test]
public function it_splits_a_multi_year_range_into_365_day_chunks(): void
{
$store = new ContributionStore(':memory:');
$calls = 0;
$provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn('github');
$provider->method('isConfigured')->willReturn(true);
$provider->method('resolveFetch')->willReturnCallback(function () use (&$calls): array {
$calls++;
return [];
});
$tester = $this->makeTester([$provider], $store);
$tester->execute(['--from' => '2020-01-01', '--to' => '2023-01-01']);
$this->assertGreaterThan(1, $calls);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Entity;
use GitContributionGraph\Entity\Contribution;
use GitContributionGraph\Entity\ContributionCollection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContributionCollectionTest extends TestCase
{
#[Test]
public function it_counts_zero_when_empty(): void
{
$collection = new ContributionCollection();
$this->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));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Entity;
use GitContributionGraph\Entity\Contribution;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContributionTest extends TestCase
{
#[Test]
public function it_exposes_provider_date_and_count(): void
{
$contribution = new Contribution('github', 1_700_000_000, 4);
$this->assertSame('github', $contribution->provider);
$this->assertSame(1_700_000_000, $contribution->date);
$this->assertSame(4, $contribution->count);
}
}
+144 -30
View File
@@ -2,10 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\Unit\Service; namespace GitContributionGraph\Tests\Unit\Service;
use App\Service\ContributionAggregator; use GitContributionGraph\Service\ContributionAggregator;
use App\Service\Provider\ProviderInterface; use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -15,16 +16,31 @@ use Psr\Log\LoggerInterface;
final class ContributionAggregatorTest extends TestCase final class ContributionAggregatorTest extends TestCase
{ {
private LoggerInterface $logger; private LoggerInterface $logger;
private ContributionStore $store;
protected function setUp(): void protected function setUp(): void
{ {
$this->logger = $this->createStub(LoggerInterface::class); $this->logger = $this->createStub(LoggerInterface::class);
$this->store = new ContributionStore(':memory:');
}
/** @param ?array<string, int> $fresh */
private function makeProvider(string $name, bool $configured = true, ?array $fresh = null): ProviderInterface
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn($name);
$provider->method('isConfigured')->willReturn($configured);
if ($fresh !== null) {
$provider->method('resolveFetch')->willReturn($fresh);
}
return $provider;
} }
#[Test] #[Test]
public function it_returns_empty_array_when_no_providers_are_given(): void public function it_returns_empty_array_when_no_providers_are_given(): void
{ {
$aggregator = new ContributionAggregator([], $this->logger); $aggregator = new ContributionAggregator([], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
@@ -34,10 +50,9 @@ final class ContributionAggregatorTest extends TestCase
#[Test] #[Test]
public function it_skips_unconfigured_providers(): void public function it_skips_unconfigured_providers(): void
{ {
$provider = $this->createStub(ProviderInterface::class); $provider = $this->makeProvider('github', configured: false);
$provider->method('isConfigured')->willReturn(false);
$aggregator = new ContributionAggregator([$provider], $this->logger); $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
@@ -47,63 +62,162 @@ final class ContributionAggregatorTest extends TestCase
#[Test] #[Test]
public function it_returns_contributions_from_a_configured_provider(): void public function it_returns_contributions_from_a_configured_provider(): void
{ {
$provider = $this->createStub(ProviderInterface::class); $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$provider->method('isConfigured')->willReturn(true); $provider = $this->makeProvider('github', fresh: [$date => 3]);
$provider->method('fetch')->willReturn(['2024-01-01' => 3]);
$aggregator = new ContributionAggregator([$provider], $this->logger); $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 3], $result); $this->assertSame([$date => 3], $result);
} }
#[Test] #[Test]
public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void
{ {
$providerA = $this->createStub(ProviderInterface::class); $dateA = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$providerA->method('isConfigured')->willReturn(true); $dateB = (new \DateTimeImmutable('-2 days'))->format('Y-m-d');
$providerA->method('fetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]);
$providerB = $this->createStub(ProviderInterface::class); $providerA = $this->makeProvider('github', fresh: [$dateA => 3, $dateB => 1]);
$providerB->method('isConfigured')->willReturn(true); $providerB = $this->makeProvider('gitlab', fresh: [$dateA => 2]);
$providerB->method('fetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger); $aggregator = new ContributionAggregator([$providerA, $providerB], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 5, '2024-01-02' => 1, '2024-01-03' => 5], $result); $this->assertSame([$dateB => 1, $dateA => 5], $result);
} }
#[Test] #[Test]
public function it_continues_fetching_remaining_providers_when_one_throws(): void public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void
{ {
$date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$failing = $this->createStub(ProviderInterface::class); $failing = $this->createStub(ProviderInterface::class);
$failing->method('getName')->willReturn('gitlab');
$failing->method('isConfigured')->willReturn(true); $failing->method('isConfigured')->willReturn(true);
$failing->method('fetch')->willThrowException(new \RuntimeException('Network error')); $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class); $healthy = $this->makeProvider('github', fresh: [$date => 7]);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('fetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger);
$result = $aggregator->aggregate(); $result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result); $this->assertSame([$date => 7], $result);
} }
#[Test] #[Test]
public function it_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 = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('warning'); $logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class); $provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn('github');
$provider->method('isConfigured')->willReturn(true); $provider->method('isConfigured')->willReturn(true);
$provider->method('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);
} }
} }
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service;
use GitContributionGraph\Service\ContributionStore;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContributionStoreTest extends TestCase
{
#[Test]
public function it_stores_and_retrieves_a_contribution(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 4);
$all = $store->all('github');
$this->assertCount(1, $all);
$this->assertSame(4, iterator_to_array($all)[0]->count);
}
#[Test]
public function it_upserts_on_a_repeated_date(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 4);
$store->add('github', 1_700_000_000, 9);
$all = $store->all('github');
$this->assertCount(1, $all);
$this->assertSame(9, iterator_to_array($all)[0]->count);
}
#[Test]
public function it_removes_a_contribution(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 4);
$store->remove('github', 1_700_000_000);
$this->assertCount(0, $store->all('github'));
}
#[Test]
public function it_merges_a_batch_of_date_counts(): void
{
$store = new ContributionStore(':memory:');
$store->merge('github', [1_700_000_000 => 1, 1_700_086_400 => 2]);
$this->assertCount(2, $store->all('github'));
}
#[Test]
public function it_reports_the_latest_date_for_a_provider(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 1);
$store->add('github', 1_700_086_400, 2);
$this->assertSame(1_700_086_400, $store->latestDate('github'));
}
#[Test]
public function it_returns_null_latest_date_when_provider_has_no_rows(): void
{
$store = new ContributionStore(':memory:');
$this->assertNull($store->latestDate('github'));
}
#[Test]
public function it_filters_all_by_since_days(): void
{
$store = new ContributionStore(':memory:');
$now = time();
$store->add('github', $now - 10 * 86400, 1);
$store->add('github', $now - 400 * 86400, 2);
$this->assertCount(1, $store->all('github', sinceDays: 30));
}
#[Test]
public function it_does_not_prune_when_retention_is_unset(): void
{
$store = new ContributionStore(':memory:', retentionDays: null);
$store->add('github', time() - 1_000 * 86400, 1);
$store->prune();
$this->assertCount(1, $store->all('github'));
}
#[Test]
public function it_prunes_rows_older_than_the_retention_window(): void
{
$store = new ContributionStore(':memory:', retentionDays: 30);
$now = time();
$store->add('github', $now - 10 * 86400, 1);
$store->add('github', $now - 40 * 86400, 2);
$store->prune();
$this->assertCount(1, $store->all('github'));
}
}
@@ -2,12 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\Unit\Service\Provider; namespace GitContributionGraph\Tests\Unit\Service\Provider;
use App\Service\Provider\GitHubProvider; use GitContributionGraph\Service\Provider\GitHubProvider;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use IDCI\Bundle\GraphQLClientBundle\Query\GraphQLQuery;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -23,28 +20,16 @@ final class GitHubProviderTest extends TestCase
string $username = 'user', string $username = 'user',
string $token = 'token', string $token = 'token',
?HttpClientInterface $client = null, ?HttpClientInterface $client = null,
?GraphQLApiClientRegistryInterface $registry = null,
): GitHubProvider { ): 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( return new GitHubProvider(
$client ?? $this->createStub(HttpClientInterface::class), $client ?? $this->createStub(HttpClientInterface::class),
$registry,
$username, $username,
$token, $token,
$this->createStub(LoggerInterface::class), $this->createStub(LoggerInterface::class),
); );
} }
/** @param array<int, array{contributionDays: array<int, array{date: string, contributionCount: int}>}> $weeks */
private function stubGraphqlResponse(array $weeks): ResponseInterface private function stubGraphqlResponse(array $weeks): ResponseInterface
{ {
$response = $this->createStub(ResponseInterface::class); $response = $this->createStub(ResponseInterface::class);
@@ -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(4, $result['2024-06-10']);
$this->assertSame(2, $result['2024-06-11']); $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); $this->assertArrayNotHasKey('2024-06-11', $result);
} }
@@ -130,7 +117,8 @@ final class GitHubProviderTest extends TestCase
$this->expectException(ServiceUnavailableHttpException::class); $this->expectException(ServiceUnavailableHttpException::class);
$this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
} }
#[Test] #[Test]
@@ -139,8 +127,32 @@ final class GitHubProviderTest extends TestCase
$client = $this->createStub(HttpClientInterface::class); $client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubGraphqlResponse([])); $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); $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);
}
} }
@@ -2,9 +2,9 @@
declare(strict_types=1); 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\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -31,6 +31,7 @@ final class GitLabProviderTest extends TestCase
); );
} }
/** @param array<int, array<string, int|string>> $data raw JSON body, e.g. events or the user lookup */
private function stubResponse(array $data): ResponseInterface private function stubResponse(array $data): ResponseInterface
{ {
$response = $this->createStub(ResponseInterface::class); $response = $this->createStub(ResponseInterface::class);
@@ -71,7 +72,8 @@ final class GitLabProviderTest extends TestCase
$this->expectException(NotFoundHttpException::class); $this->expectException(NotFoundHttpException::class);
$this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
} }
#[Test] #[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(2, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']); $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(2, $callCount);
$this->assertSame(100, $result['2024-06-10']); $this->assertSame(100, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']); $this->assertSame(1, $result['2024-06-11']);
} }
#[Test]
public function it_sends_after_and_before_query_params_when_since_and_until_are_given(): void
{
$capturedQuery = null;
$client = $this->createMock(HttpClientInterface::class);
$client->method('request')->willReturnCallback(
function (string $method, string $url, array $options = []) use (&$capturedQuery): ResponseInterface {
if (str_contains($url, '/events')) {
$capturedQuery = $options['query'];
}
return $this->stubResponse(str_contains($url, '/events') ? [] : [['id' => 42]]);
}
);
$provider = $this->makeProvider(client: $client);
$provider->startFetch(new \DateTimeImmutable('2024-01-01'), new \DateTimeImmutable('2024-01-31'));
$this->assertSame('2024-01-01', $capturedQuery['after']);
$this->assertSame('2024-01-31', $capturedQuery['before']);
}
} }
@@ -2,9 +2,9 @@
declare(strict_types=1); 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\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -30,6 +30,7 @@ final class GiteaProviderTest extends TestCase
); );
} }
/** @param array<int, array{timestamp: int, contributions: int}> $data */
private function stubResponse(array $data): ResponseInterface private function stubResponse(array $data): ResponseInterface
{ {
$response = $this->createStub(ResponseInterface::class); $response = $this->createStub(ResponseInterface::class);
@@ -78,7 +79,8 @@ final class GiteaProviderTest extends TestCase
['timestamp' => $now, 'contributions' => 5], ['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)]); $this->assertSame(5, $result[date('Y-m-d', $now)]);
} }
@@ -93,7 +95,8 @@ final class GiteaProviderTest extends TestCase
['timestamp' => $old, 'contributions' => 3], ['timestamp' => $old, 'contributions' => 3],
])); ]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result); $this->assertSame([], $result);
} }
@@ -104,8 +107,49 @@ final class GiteaProviderTest extends TestCase
$client = $this->createStub(HttpClientInterface::class); $client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([])); $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); $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);
}
} }
@@ -2,12 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\Unit\Service\Provider; namespace GitContributionGraph\Tests\Unit\Service\Provider;
use App\Service\Provider\ProbeTrait; use GitContributionGraph\Service\Provider\ProbeTrait;
use App\Service\Provider\ProviderErrorCode; use GitContributionGraph\Service\Provider\ProviderErrorCode;
use App\Service\Provider\ProviderInterface; use GitContributionGraph\Service\Provider\ProviderInterface;
use App\Service\Provider\ProviderStatusType; use GitContributionGraph\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -31,7 +31,8 @@ final class ProbeTraitTest extends TestCase
public function isConfigured(): bool { return $this->configured; } public function isConfigured(): bool { return $this->configured; }
public function getName(): string { return 'test'; } public function getName(): string { return 'test'; }
public function ping(): void { ($this->ping)(); } public function ping(): void { ($this->ping)(); }
public function fetch(): array { return []; } public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed { return null; }
public function resolveFetch(mixed $handle): array { return []; }
}; };
} }
@@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\Unit\Service\Provider; namespace GitContributionGraph\Tests\Unit\Service\Provider;
use App\Service\Provider\ProviderErrorCode; use GitContributionGraph\Service\Provider\ProviderErrorCode;
use App\Service\Provider\ProviderHealthChecker; use GitContributionGraph\Service\Provider\ProviderHealthChecker;
use App\Service\Provider\ProviderInterface; use GitContributionGraph\Service\Provider\ProviderInterface;
use App\Service\Provider\ProviderStatus; use GitContributionGraph\Service\Provider\ProviderStatus;
use App\Service\Provider\ProviderStatusType; use GitContributionGraph\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\Unit\Service\Provider; namespace GitContributionGraph\Tests\Unit\Service\Provider;
use App\Service\Provider\ProviderErrorCode; use GitContributionGraph\Service\Provider\ProviderErrorCode;
use App\Service\Provider\ProviderStatus; use GitContributionGraph\Service\Provider\ProviderStatus;
use App\Service\Provider\ProviderStatusType; use GitContributionGraph\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -2,9 +2,9 @@
declare(strict_types=1); 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\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@@ -54,6 +54,7 @@ final class SvgRendererTest extends TestCase
$this->assertStringContainsString($expectedColor, $svg); $this->assertStringContainsString($expectedColor, $svg);
} }
/** @return iterable<string, array{string, string}> */
public static function theme_background_provider(): iterable public static function theme_background_provider(): iterable
{ {
yield 'dark theme' => ['dark', '#0d1117']; yield 'dark theme' => ['dark', '#0d1117'];
@@ -97,6 +98,7 @@ final class SvgRendererTest extends TestCase
$this->assertStringContainsString('>' . $label . '<', $svg); $this->assertStringContainsString('>' . $label . '<', $svg);
} }
/** @return iterable<string, array{string}> */
public static function day_of_week_label_provider(): iterable public static function day_of_week_label_provider(): iterable
{ {
yield 'Monday' => ['Mon']; yield 'Monday' => ['Mon'];