54 Commits
Author SHA1 Message Date
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
haylan 980463f715 Merge pull request 'Update renovate.json' (#10) from change-renovate-configuration into main
Reviewed-on: #10
2026-06-04 00:44:33 +02:00
haylan 095b9675f9 Update renovate.json 2026-06-04 00:44:18 +02:00
haylan 8dbfef8496 Merge pull request 'refactor: reorgeniced Service/ into Provider/ and Renderer/ sub-namespaces' (#9) from refactor/service-namespaces into main
Reviewed-on: #9
2026-06-04 00:42:43 +02:00
haylan a527eada56 Merge pull request 'feat: add contribution graph favicon (ICO + PNG)' (#8) from feat/favicon into main
Reviewed-on: #8
2026-06-04 00:42:37 +02:00
haylan 423ac5470d Merge branch 'main' into refactor/service-namespaces 2026-06-04 00:42:28 +02:00
haylan 4e992c8f79 Merge branch 'main' into feat/favicon 2026-06-04 00:42:11 +02:00
haylan 4983492088 Merge pull request 'chore(config): migrate Renovate config' (#7) from renovate/migrate-config into main
Reviewed-on: #7
2026-06-04 00:31:09 +02:00
renovate-bot 169fa8c76a chore(config): migrate config renovate.json 2026-06-03 22:29:25 +00:00
haylan 4cefba1a37 Merge pull request 'chore: Configure Renovate' (#5) from renovate/configure into main
Reviewed-on: #5
2026-06-03 10:42:26 +02:00
haylan 28a0916487 chore(renovate): pinned shymfony version, seperated major and minor. removed major upgrades 2026-06-03 10:37:49 +02:00
renovate-bot 4cfa61f1c0 Add renovate.json 2026-06-03 08:27:12 +00:00
haylanandClaude Sonnet 4.6 2f3268c0b7 refactor: reorganise Service/ into Provider/ and Renderer/ sub-namespaces
Move all provider-related classes, enums, interface and trait into
App\Service\Provider; move SvgRenderer into App\Service\Renderer.
ContributionAggregator stays at the Service root as the orchestrator.
Test namespaces and use statements updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 00:10:09 +02:00
haylanandClaude Sonnet 4.6 89d0e2b0f6 feat: add contribution graph favicon (ICO + PNG)
Multi-size favicon.ico (16×16, 32×32, 48×48) and favicon.png built from
a 5×5 GitHub-green contribution graph grid on a dark #0d1117 background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 00:00:06 +02:00
haylan fad176419c Merge pull request 'Fix/docker production' (#4) from fix/docker-production into main
Reviewed-on: #4
2026-05-30 23:43:06 +02:00
haylan 3743ba31d2 Merge branch 'main' into fix/docker-production 2026-05-30 23:42:57 +02:00
haylan 8a675cf02f fix: aligen docker image with symfyon stu 2026-05-30 17:28:59 +02:00
haylan 20c5acc5ae Merge pull request 'Update README.md' (#3) from haylan-patch-1 into main
Reviewed-on: #3
2026-05-30 16:51:50 +02:00
haylan 21867256e8 Update README.md 2026-05-30 16:51:42 +02:00
haylan 67d4a50ee1 chroe: removed test 2026-05-30 16:29:31 +02:00
haylan e72ee2541e fix: fixed build stages and docker images. Docker image is now sleeker. The pulbish build should now have less garbage 2026-05-30 16:14:51 +02:00
haylanandClaude Sonnet 4.6 ecdb8c1716 fix(docker): commit composer.lock and install intl extension
- Remove composer.lock from .gitignore so it is tracked and included in
  the build context; without it composer install resolves fresh versions
  on every CI build, causing cache/vendor mismatches that produce the
  RewindableGenerator and LazyGhostTrait runtime errors in prod
- Install the intl PHP extension in the base stage to remove the
  Symfony startup deprecation warning; icu-dev and libzip-dev are only
  kept for the compile step, then deleted to keep the layer lean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:41:17 +02:00
haylan 841f4329de feat: removed patters x.x and x 2026-05-30 15:29:46 +02:00
haylanandClaude Sonnet 4.6 50256c97ef build(docker): warm prod cache at build time and add prod compose file
- Run cache:warmup in the build stage so containers start with a
  pre-built Symfony kernel and DI container
- Scope the cache volume to var/cache/prod/pools where Symfony writes
  pool data, preserving the warmed kernel across container restarts
- Add docker-compose.prod.yml for deploying the registry image without
  the dev override being picked up automatically
- Expand .dockerignore to exclude vendor/, tests/, docs, compose files,
  and env files from the build context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:15:51 +02:00
49 changed files with 5994 additions and 275 deletions
+32 -1
View File
@@ -1,6 +1,37 @@
# Build descriptors (not application code)
Dockerfile*
# Version control
.git
.gitea
# AI / IDE tooling
.claude
# Dev tooling
.gitignore
/.phpunit.cache
phpunit.xml.dist
tests/
# Documentation
CHANGELOG.md
CLAUDE.md
README.md
# Compose / deployment descriptors (not app code)
docker-compose.yml
docker-compose.override.yml
docker-compose.prod.yml
# Dependencies — re-installed from lockfile in the build stage;
# a local vendor/ in the build context would silently override the clean install
vendor/
# Runtime dirs (generated at build or run time, not from source)
var/
# Env files — .env contains only placeholder defaults and is needed by composer dump-env;
# local overrides with real secrets stay excluded
.env.local
.env.*.local
docker-compose.override.yml
+13 -3
View File
@@ -15,7 +15,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (semver, e.g. 1.2.3)'
description: "Release tag (semver, e.g. 1.2.3)"
required: true
type: string
@@ -55,8 +55,6 @@ jobs:
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
tags: |
type=semver,pattern={{version}},value=${{ inputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ inputs.tag }}
type=semver,pattern={{major}},value=${{ inputs.tag }}
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
@@ -75,10 +73,22 @@ jobs:
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
# Build a single-arch image locally so Trivy can inspect it before the real push.
- name: Build local image for scanning
uses: docker/build-push-action@v5
with:
context: .
target: final
platforms: linux/amd64
load: true
tags: scan-target:${{ inputs.tag }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
target: final
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
-1
View File
@@ -3,5 +3,4 @@
/vendor/
/var/
/public/bundles/
composer.lock
/.phpunit.cache
+27 -25
View File
@@ -1,9 +1,16 @@
#syntax=docker/dockerfile:1
FROM dunglas/frankenphp:1-php8.4-alpine AS base
RUN apk add --no-cache \
curl \
icu-libs \
libzip
RUN apk add --no-cache icu-dev libzip-dev \
&& docker-php-ext-install -j$(nproc) intl opcache zip \
&& apk del icu-dev libzip-dev \
&& apk add --no-cache curl icu-libs libzip \
&& cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
&& mkdir -p $PHP_INI_DIR/app.conf.d
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
ENV COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app
@@ -21,34 +28,29 @@ RUN composer install \
# ── build stage (generate optimised classmap with source present) ──────────────
FROM deps AS build
COPY . .
RUN composer dump-autoload --optimize --no-dev --no-interaction
# ── dev stage (all deps + Xdebug, source is mounted at runtime) ───────────────
FROM base AS dev
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN apk add --no-cache ${PHPIZE_DEPS} linux-headers \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug \
&& apk del ${PHPIZE_DEPS}
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-xdebug.ini
COPY docker/frankenphp/Caddyfile.dev /etc/caddy/Caddyfile
COPY composer.json composer.lock* ./
RUN composer install --no-scripts --no-interaction --prefer-dist
EXPOSE 8080
ENV APP_ENV=dev APP_DEBUG=1
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
RUN composer dump-autoload --classmap-authoritative --no-dev --no-interaction && \
mkdir -p var/cache var/log && \
APP_ENV=prod APP_SECRET=placeholder php bin/console cache:warmup --no-debug && \
composer dump-env prod
# ── final (prod) stage — no composer binary ────────────────────────────────────
FROM base AS final
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /app/vendor /app/vendor
COPY . .
COPY docker/frankenphp/Caddyfile /etc/caddy/Caddyfile
COPY --link --from=build /app/vendor /app/vendor
COPY --link --from=build /app/var/cache/prod /app/var/cache/prod
COPY --link bin/ ./bin/
COPY --link config/ ./config/
COPY --link public/ ./public/
COPY --link src/ ./src/
COPY --link composer.json composer.lock ./
COPY --link docker/frankenphp/Caddyfile /etc/caddy/Caddyfile
COPY --link docker/php/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
RUN mkdir -p var/cache var/log \
&& chown -R app:app /app
RUN chmod +x bin/console && \
mkdir -p var/cache/prod/pools var/log /config/caddy /data/caddy && \
chown -R app:app /app /config /data
USER app
+30
View File
@@ -0,0 +1,30 @@
FROM dunglas/frankenphp:1-php8.4-alpine AS dev
RUN apk add --no-cache icu-dev libzip-dev \
&& docker-php-ext-install -j$(nproc) intl opcache zip \
&& apk del icu-dev libzip-dev \
&& apk add --no-cache curl icu-libs libzip \
&& cp "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" \
&& mkdir -p $PHP_INI_DIR/app.conf.d
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN apk add --no-cache ${PHPIZE_DEPS} linux-headers \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug \
&& apk del ${PHPIZE_DEPS}
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
ENV COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-xdebug.ini
COPY docker/php/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/
COPY docker/frankenphp/Caddyfile.dev /etc/caddy/Caddyfile
COPY composer.json composer.lock* ./
RUN composer install --no-scripts --no-interaction --prefer-dist
EXPOSE 8080
ENV APP_ENV=dev APP_DEBUG=1
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
+6 -6
View File
@@ -29,7 +29,7 @@ https://your-host/graph.svg?theme=dark
### 1. Create a `docker-compose.yml`
Use the pre-built image — no need to clone the repo:
Use the pre-built image
```yaml
services:
@@ -175,7 +175,7 @@ docker compose up -d --build
docker compose exec graph sh
# Run tests inside the container
docker compose exec graph php bin/phpunit
docker compose exec graph vendor/bin/phpunit
# Disable Xdebug for faster test runs
XDEBUG_MODE=off docker compose up -d
@@ -195,16 +195,16 @@ docker compose -f docker-compose.yml up -d --build
```bash
# Run full suite
php bin/phpunit
vendor/bin/phpunit
# Human-readable output
php bin/phpunit --testdox
vendor/bin/phpunit --testdox
# Single file
php bin/phpunit tests/Unit/Service/SvgRendererTest.php
vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Filter by name
php bin/phpunit --filter it_renders
vendor/bin/phpunit --filter it_renders
```
---
+266
View File
@@ -0,0 +1,266 @@
# TODO: persist contribution history in SQLite + audit cleanup
Backend-only work. Follow Red → Green → Refactor per `CLAUDE.md` for every
step that adds behavior (store, retention, provider `$since` handling).
Order matters — later steps assume earlier ones are done.
## 1. Audit cleanup
- [x] **Remove `eightpoints/guzzle-bundle` + `idci/graphql-client-bundle`.**
They exist only to build one near-static GitHub GraphQL query string
(`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already
bypassed (comment at `GitHubProvider.php:76-77`).
- [x] Replace
`$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain
`sprintf`/heredoc query string. Delete the two packages from
`composer.json`, `config/bundles.php`, and delete
`config/packages/eight_points_guzzle.yaml` +
`config/packages/idci_graphql_client.yaml`. Run `composer update` and
commit the regenerated `composer.lock`.
- [x] **Dedupe `baseUrl` normalization.** `GitLabProvider.php` (lines 41,
53) and `GiteaProvider.php` (lines 42, 54) each call
`rtrim($this->baseUrl..., '/')` twice — once in `ping()`, once in
`fetch()`. Compute it once as a `private readonly string $baseUrl` in
the constructor instead.
## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4)
- [x] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the
pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and
`_instanceof: App\Service\ProviderInterface`, left over from the
`Service/``Provider/`+`Renderer/` namespace reorg. Update all of
these to `App\Service\Provider\...`. Currently these bindings silently
no-op, and scalar constructor args can't autowire without them — any
edit to these constructors (steps 1, 4) needs this fixed first, or the
container fails to compile.
## 3. `ContributionStore` (SQLite via native PDO)
> Docs: [PDO](https://www.php.net/manual/en/book.pdo.php) ·
> [PDO_SQLITE driver](https://www.php.net/manual/en/ref.pdo-sqlite.php) ·
> [PDO::prepare / prepared statements](https://www.php.net/manual/en/pdo.prepare.php) ·
> [SQLite `INSERT ... ON CONFLICT` upsert](https://www.sqlite.org/lang_upsert.html) ·
> [SQLite `STRICT` tables](https://www.sqlite.org/stricttables.html) ·
> [SQLite `WITHOUT ROWID` tables](https://www.sqlite.org/withoutrowid.html) ·
> [SQLite datatypes (why `date` is `INTEGER`/unixtime, not `TEXT`)](https://www.sqlite.org/datatype3.html)
- [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at
`%kernel.project_dir%/var/data/contributions.db` (configurable
constructor arg), table created lazily. **Schema note:** `date` is
stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the
current implementation:
```sql
CREATE TABLE IF NOT EXISTS contributions (
provider TEXT NOT NULL,
date INTEGER NOT NULL,
count INTEGER NOT NULL CHECK (count >= 0),
PRIMARY KEY (provider, date)
) WITHOUT ROWID, STRICT;
```
- [x] `add(string $provider, int $unixtime, int $count): void` — plain
insert (done, needs test coverage — see below).
- [x] Fix `add()`: currently a plain `INSERT`, so re-adding an existing
`(provider, date)` throws a unique-constraint violation instead of
upserting. Switch to
`INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`
(see SQLite upsert doc above), or keep `add()` insert-only and add a
separate `merge()` for the upsert case used by step 5.
- [x] `remove(string $provider, int $unixtime): void` — started, has a
bug: `DELETE contributions WHERE ...` is invalid SQL, missing the
`FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also
drop the `LIKE` on `provider` (exact match, use `=`) since it's an
unindexed wildcard scan for what should be an exact key lookup.
- [x] **`Contribution` entity.** Small immutable value object
(`src/Entity/Contribution.php`) wrapping one row: `provider` (string),
`date` (unix timestamp int, or `\DateTimeImmutable` — pick one and
use it consistently everywhere, including `add()`/`all()`), `count`
(int). Gives `ContributionStore::all()` and the aggregator something
typed to pass around instead of raw arrays/tuples.
- [x] **`ContributionCollection`.** Typed collection
(`src/Entity/ContributionCollection.php`) wrapping
`array<Contribution>` — implement `IteratorAggregate` + `Countable`
at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php),
[`Countable`](https://www.php.net/manual/en/class.countable.php)) so
it can be `foreach`'d and `count()`'d like a normal array. This is
what `ContributionStore::all()` should return instead of a bare
`date => count` array.
- [x] `latestDate(string $provider): ?int` — `SELECT MAX(date) WHERE provider = ?`
(returns a unix timestamp, not a string, per the schema above).
- [x] `merge(string $provider, array $dateCounts): void` — upsert via
`INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`;
`$dateCounts` keyed by unix timestamp.
- [x] `all(string $provider, ?int $sinceDays = null): ContributionCollection` —
fetch all rows for a provider, or since a given range. `null` returns
full history (no arbitrary cutoff), a value filters to rows where
`date >= (now - sinceDays * 86400)`. **Bug:** current stub in
`ContributionStore.php:58` has invalid PHP (`$provider == null` instead
of `?string $provider = null` — this won't parse) and returns `void`
instead of `ContributionCollection`; fix the signature when implementing.
- [x] `prune(): void``DELETE FROM contributions WHERE date < ?` using
the configured retention window (a unix timestamp cutoff); no-ops if
retention is unset/0.
- [x] Constructor takes `?int $retentionDays` bound from new env var
`CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). *(constructor
param wired; the env binding itself is step 6's job.)*
- [x] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store &
retrieve, upsert overwrites existing date, `sinceDays` filtering,
`prune()` no-ops when retention unset, `prune()` deletes rows older
than the window when set.
- [x] Tests: `tests/Unit/Entity/ContributionTest.php` and
`ContributionCollectionTest.php` — construction, iteration, `count()`.
## 4. Wire `$since`/`$until` through providers
Root cause of the live `MaxExecutionTimeError` crash: every cache-miss
request re-fetches the full ~365-day history from every host, and
GitLab's pagination is unbounded and sequential. Bounding the fetch
window (step 5's 3-day trailing overlap) is what actually fixes it — a
PHP `max_execution_time` fatal can't be caught by `try/catch` at all, so
"catch it better" was never on the table.
- [ ] `ProviderInterface::startFetch()``startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed`
(interface already split into `startFetch`/`resolveFetch` for
concurrency — this doc previously said `fetch()`, which predates that
split).
- [ ] `GitHubProvider::startFetch()` — already builds explicit `from`/`to`
GraphQL args (`GitHubProvider.php:51-58`); swap the hardcoded
`-365 days`/`now` for `$since ?? -365 days` / `$until ?? now`.
- [ ] `GitLabProvider::startFetch()` — add a `before` query param alongside
the existing `after` (`GitLabProvider.php:77-84`), fed by `$until`;
`$since` already flows into `after` (this is what actually shrinks the
pagination loop).
- [ ] `GiteaProvider::resolveFetch()` — heatmap endpoint has no query
params (always returns full history); add an `$until` upper-bound
filter alongside the existing `$cutoff` lower bound.
- [ ] Update `GitHubProviderTest.php`, `GitLabProviderTest.php`,
`GiteaProviderTest.php` — add cases asserting a passed `$since`/`$until`
narrows the request window/query params.
## 5. Wire `ContributionStore` into `ContributionAggregator`
- [ ] Fix `ContributionStore` wiring in `config/services.yaml` first —
it's currently dead code (nothing calls it). The constructor default
`$dbPath` string (`%kernel.project_dir%/var/data/contributions.db`) is
a plain PHP default, not a resolved container parameter; bind it
explicitly (same pattern as the provider bindings):
```yaml
App\Service\ContributionStore:
arguments:
$dbPath: '%kernel.project_dir%/var/data/contributions.db'
$retentionDays: '%env(int:CONTRIBUTIONS_RETENTION_DAYS)%'
```
Add `env(CONTRIBUTIONS_RETENTION_DAYS): ''` to `parameters:` (empty →
casts to `0` → `prune()`'s existing `retentionDays === 0` check already
treats that as "keep forever"). Document the var in `.env`.
- [ ] Inject `ContributionStore` into `ContributionAggregator`.
- [ ] Per configured provider: `$latest = $store->latestDate($name)` →
`$since = $latest !== null ? (new \DateTimeImmutable('@' . $latest))->modify('-3 days') : null`
(3-day overlap for late corrections — old stored days are immutable and
never re-fetched, only this trailing window + anything new hits the
network), else `null` (first run, provider's own default lookback).
`$until = null` (always "up to now" on the normal request path).
- [ ] `startFetch($since, $until)` / `resolveFetch()` as today,
`$store->merge($name, $fresh)` on success, then read back
`$store->all($name, sinceDays: 371)` for the render window
(53 weeks × 7 days) — a `ContributionCollection` — and merge its
contributions into the returned array (the store becomes the source
of truth for what gets rendered, not the fresh fetch alone).
- [ ] Call `$store->prune()` once per `aggregate()` call, after all
providers have merged.
- [ ] Keep the existing try/catch-and-log-per-provider behavior — a
provider failure leaves its DB history stale, doesn't break the render.
- [ ] Update `ContributionAggregatorTest.php` with store-interaction
(`latestDate` consulted, `merge` called, `all()` feeds the result,
`prune()` runs once) and a case confirming a provider failure leaves
other providers' stored data intact.
## 6. Docker / env
- [ ] `docker-compose.yml` — add a `data` named volume mounted at
`/app/var/data` (same pattern as `cache`/`logs`), and pass through
`CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-}"`.
- [ ] `Dockerfile` — add `var/data` to the `mkdir -p` in the `final` stage
alongside `var/cache/prod/pools var/log`, owned by `app`.
- [ ] `.env` — document `CONTRIBUTIONS_RETENTION_DAYS` (empty by default),
same style as the existing `ALLOWED_HOSTS` comment.
## 9. `app:contributions:refetch` console command
Manual escape hatch for forcing a full or ranged re-fetch (e.g. after
adding a new host, or if the store needs rebuilding) — bypasses step 5's
incremental `latestDate()` window entirely for the providers/range given.
- [ ] `src/Command/RefetchContributionsCommand.php`, `#[AsCommand]` +
constructor-promoted readonly properties (matches this codebase's
attribute-first style), using `SymfonyStyle`
([console styling guide](https://symfony.com/doc/current/console/style.html)).
Inject `iterable $providers` (`#[AutowireIterator('app.provider')]`,
same as `ContributionAggregator`) and `ContributionStore`.
- [ ] Options: `--provider=github,gitlab` (repeatable/comma-split,
restricts to named provider(s), default = all configured; unknown name
→ `$io->error()` + `Command::FAILURE`), `--from=YYYY-MM-DD` (default
today 365 days), `--to=YYYY-MM-DD` (default today), `--all` (shorthand
for `--from` far enough back — e.g. 2005-01-01 — to mean "existing full
history"; combinable with `--provider`).
- [ ] **Batch by range**: split `[from, to]` into ≤365-day chunks
(GitHub's GraphQL `contributionsCollection` rejects windows over a
year; chunking also caps GitLab's pagination per call, so a big
`--all` re-fetch can't reintroduce the original runaway-pagination
timeout). Iterate chunks oldest-first.
- [ ] Per provider, per chunk: `$io->section(...)`, `startFetch($chunkFrom, $chunkTo)`
/ `resolveFetch()`, `$store->merge($name, $result)` immediately (don't
accumulate all chunks in memory), `ProgressBar` across chunks.
- [ ] Catch per-provider/per-chunk `\Throwable` → `$io->warning(...)`,
continue with remaining chunks/providers (same graceful-degradation
philosophy as `ContributionAggregator`).
- [ ] Finish with `$io->table(...)` summary (provider, days written,
chunks fetched, any errors) and `$io->success()`/`$io->error()`; exit
`Command::SUCCESS` if at least one provider fully succeeded, else
`Command::FAILURE`.
- [ ] Tests: `tests/Unit/Command/RefetchContributionsCommandTest.php`
using `CommandTester` with fake `ProviderInterface` stubs and a
`:memory:` `ContributionStore` — assert chunking count for a >365-day
range, store ends up populated, unknown `--provider` name fails
cleanly, a provider throwing doesn't abort the others.
## 10. PHPStan
- [ ] `composer require --dev phpstan/phpstan` (plain PHPStan — no
Symfony extension needed for this app's size).
- [ ] Add `phpstan.neon`: `paths: [src, tests]`, `level: 8`.
- [ ] Add composer script `"phpstan": "phpstan analyse"`.
- [ ] Fix whatever level-8 flags on first run.
## 11. `CLAUDE.md` update
- [ ] Architecture section: add the `ContributionStore` (SQLite) tier
between providers and the renderer; note the two-tier cache (1h SVG
cache → SQLite raw-data store → provider APIs).
- [ ] Environment variables table: add `CONTRIBUTIONS_RETENTION_DAYS`.
- [ ] Document `app:contributions:refetch` (options, batching behavior).
- [ ] Development section: add `composer phpstan` next to the existing
`vendor/bin/phpunit` commands.
- [ ] Remove any remaining mentions of the two deleted bundles.
## Verification
- `vendor/bin/phpunit --testdox` green after every step.
- `composer phpstan` clean at level 8.
- `composer install` succeeds with no dangling references to the removed bundles.
- `docker compose up -d --build`; clear the FS cache
(`docker compose exec graph rm -rf var/cache/*`); hit
`/graph.svg?theme=dark` twice; check `docker compose logs -f graph` shows
a narrower `since` window on the second fetch.
- Set `CONTRIBUTIONS_RETENTION_DAYS=30`; confirm rows older than 30 days
are gone after the next refresh
(`sqlite3 var/data/contributions.db "SELECT date FROM contributions ORDER BY date LIMIT 1"`).
- `curl localhost:8080/health` still reports all configured providers healthy.
- `docker compose exec graph bin/console app:contributions:refetch --all`
populates `var/data/contributions.db` from empty; re-run with no flags
and confirm logs show only the trailing-window re-fetch.
- `docker compose exec graph bin/console app:contributions:refetch --provider=github --from=2020-01-01 --to=2023-01-01`
exercises multi-chunk batching.
- Confirm the original crash scenario is gone: an account with heavy
GitLab history no longer triggers `MaxExecutionTimeError` on a cold
cache (bounded by the 3-day trailing window, not full history).
-2
View File
@@ -5,8 +5,6 @@
"license": "MIT",
"require": {
"php": ">=8.2",
"eightpoints/guzzle-bundle": "^8.6",
"idci/graphql-client-bundle": "^2.0",
"monolog/monolog": "^3.10",
"symfony/cache": "7.4.*",
"symfony/console": "7.4.*",
Generated
+4948
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -3,6 +3,4 @@
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true],
IDCI\Bundle\GraphQLClientBundle\IDCIGraphQLClientBundle::class => ['all' => true],
];
-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:
app: cache.adapter.filesystem
default_redis_provider: 'redis://localhost'
http_client:
default_options:
timeout: 10 # connect + wait-for-first-byte cap per request
max_duration: 15 # hard cap on total request duration
-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 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 CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator
* @psalm-type DeprecationType = array{package: string, version: string, message?: string}
* @psalm-type DefaultsType = array{
* public?: bool,
@@ -121,7 +121,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type ServicesConfig = array{
* _defaults?: DefaultsType,
* _instanceof?: InstanceofType,
* _instanceof?: array<class-string, InstanceofType>,
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
* }
* @psalm-type ExtensionType = array<string, mixed>
@@ -302,7 +302,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* },
* translator?: bool|array{ // Translator configuration
* enabled?: bool|Param, // Default: true
* enabled?: bool|Param, // Default: false
* fallbacks?: string|list<scalar|Param|null>,
* logging?: bool|Param, // Default: false
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
@@ -833,72 +833,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* }>,
* }
* @psalm-type EightPointsGuzzleConfig = array{
* clients?: array<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{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -906,8 +852,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,
+29 -25
View File
@@ -1,14 +1,14 @@
parameters:
env(APP_SECRET): ''
env(ALLOWED_HOSTS): ''
env(GITHUB_USER): ''
env(GITHUB_TOKEN): ''
env(GITLAB_USER): ''
env(GITLAB_TOKEN): ''
env(GITLAB_URL): ''
env(GITEA_USER): ''
env(GITEA_TOKEN): ''
env(GITEA_URL): ''
env(APP_SECRET): ""
env(ALLOWED_HOSTS): ""
env(GITHUB_USER): ""
env(GITHUB_TOKEN): ""
env(GITLAB_USER): ""
env(GITLAB_TOKEN): ""
env(GITLAB_URL): ""
env(GITEA_USER): ""
env(GITEA_TOKEN): ""
env(GITEA_URL): ""
services:
_defaults:
@@ -17,27 +17,31 @@ services:
public: false
App\:
resource: '../src/'
resource: "../src/"
exclude:
- '../src/Kernel.php'
- "../src/Kernel.php"
_instanceof:
App\Service\ProviderInterface:
tags: ['app.provider']
App\Service\Provider\ProviderInterface:
tags: ["app.provider"]
App\Service\GitHubProvider:
App\Service\Provider\GitHubProvider:
arguments:
$username: '%env(GITHUB_USER)%'
$token: '%env(GITHUB_TOKEN)%'
$username: "%env(GITHUB_USER)%"
$token: "%env(GITHUB_TOKEN)%"
App\Service\GitLabProvider:
App\Service\Provider\GitLabProvider:
arguments:
$username: '%env(GITLAB_USER)%'
$token: '%env(GITLAB_TOKEN)%'
$baseUrl: '%env(GITLAB_URL)%'
$username: "%env(GITLAB_USER)%"
$token: "%env(GITLAB_TOKEN)%"
$baseUrl: "%env(GITLAB_URL)%"
App\Service\GiteaProvider:
App\Service\Provider\GiteaProvider:
arguments:
$username: '%env(GITEA_USER)%'
$token: '%env(GITEA_TOKEN)%'
$baseUrl: '%env(GITEA_URL)%'
$username: "%env(GITEA_USER)%"
$token: "%env(GITEA_TOKEN)%"
$baseUrl: "%env(GITEA_URL)%"
App\Service\ContributionStore:
arguments:
$dbPath: "%kernel.project_dir%/var/data/contributions.db"
$retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%"
+3 -1
View File
@@ -3,10 +3,12 @@
services:
graph:
build:
dockerfile: Dockerfile.dev
target: dev
volumes:
- .:/app
- /app/vendor # keeps vendor from the dev image, not your local dir
- ./vendor:/app/vendor
- /app/var/data
environment:
APP_ENV: dev
APP_DEBUG: "1"
+32
View File
@@ -0,0 +1,32 @@
services:
graph:
image: git.arthurerlich.de/haylan/git-contribution-graph:latest
container_name: git-contribution-graph
restart: unless-stopped
ports:
- "8080:8080"
environment:
APP_ENV: prod
APP_DEBUG: "0"
APP_SECRET: "${APP_SECRET}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS:-}"
GITHUB_USER: "${GITHUB_USER:-}"
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
GITLAB_USER: "${GITLAB_USER:-}"
GITLAB_TOKEN: "${GITLAB_TOKEN:-}"
GITLAB_URL: "${GITLAB_URL:-}"
GITEA_USER: "${GITEA_USER:-}"
GITEA_TOKEN: "${GITEA_TOKEN:-}"
GITEA_URL: "${GITEA_URL:-}"
volumes:
- cache:/app/var/cache/prod/pools
- logs:/app/var/log
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
cache:
logs:
+2 -1
View File
@@ -21,13 +21,14 @@ services:
GITEA_TOKEN: "${GITEA_TOKEN:-}"
GITEA_URL: "${GITEA_URL:-}"
volumes:
- cache:/app/var/cache
- cache:/app/var/cache/prod/pools
- logs:/app/var/log
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
cache:
+2
View File
@@ -0,0 +1,2 @@
opcache.validate_timestamps=1
opcache.revalidate_freq=0
+6
View File
@@ -0,0 +1,6 @@
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
expose_php=0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"separateMajorMinor": true,
"major": {
"enabled": false
},
"packageRules": [
{
"allowedVersions": "^7.0",
"description": "Keep Symfony on 7.x LTS until EOL",
"matchPackageNames": [
"/^symfony//"
]
},
{
"matchManagers": ["github-actions"],
"enabled": false,
"description": "Ignore GitHub Actions — no GITHUB_COM_TOKEN available"
},
{
"matchPackageNames": ["phpunit/phpunit"],
"allowedVersions": "^12.0",
"description": "Stay on PHPUnit 12.x (compatible with Symfony 7 / PHP 8.2+)"
}
]
}
+3 -2
View File
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Controller;
use App\Service\ContributionAggregator;
use App\Service\ProviderHealthChecker;
use App\Service\SvgRenderer;
use App\Service\Provider\ProviderHealthChecker;
use App\Service\Renderer\SvgRenderer;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -53,6 +53,7 @@ final class GraphController
$svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string {
$cacheMiss = true;
$item->expiresAfter(3600);
set_time_limit(30); // ponytail: providers fetch concurrently now (cost ~= max, not sum); GitLab pagination + per-request HTTP timeout (framework.yaml) still need headroom over the 30s default
return $this->renderer->render($this->aggregator->aggregate(), $theme);
});
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Entity;
final class Contribution
{
public function __construct(
public readonly string $provider,
public readonly int $date,
public readonly int $count,
) {
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\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 = $contributions;
}
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->contributions);
}
public function count(): int
{
return count($this->contributions);
}
}
+13 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Service\Provider\ProviderInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
@@ -18,7 +19,7 @@ final class ContributionAggregator
/** @return array<string, int> */
public function aggregate(): array
{
$contributions = [];
$pending = [];
/** @var ProviderInterface $provider */
foreach ($this->providers as $provider) {
@@ -27,7 +28,17 @@ final class ContributionAggregator
}
try {
foreach ($provider->fetch() as $date => $count) {
$pending[] = [$provider, $provider->startFetch()];
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
$contributions = [];
foreach ($pending as [$provider, $handle]) {
try {
foreach ($provider->resolveFetch($handle) as $date => $count) {
$contributions[$date] = ($contributions[$date] ?? 0) + $count;
}
} catch (\Throwable $e) {
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Contribution;
use App\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.
*/
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.
*/
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.
*/
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 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.
*/
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.
*/
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);
$contributions = array_map(
static fn(array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']),
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
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]);
}
}
@@ -2,13 +2,12 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Fetches the last 365 days of contributions from the GitHub GraphQL API.
@@ -23,7 +22,6 @@ final class GitHubProvider implements ProviderInterface
public function __construct(
private readonly HttpClientInterface $client,
private readonly GraphQLApiClientRegistryInterface $registry,
private readonly string $username,
private readonly string $token,
private readonly LoggerInterface $logger,
@@ -46,44 +44,39 @@ final class GitHubProvider implements ProviderInterface
])->getContent();
}
/**
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function fetch(): array
public function startFetch(): ResponseInterface
{
$this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]);
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
/** @var GraphQLApiClient $graphqlClient */
$graphqlClient = $this->registry->get('github');
$query = sprintf(
'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }',
json_encode($this->username),
json_encode($from),
json_encode($to),
);
$query = $graphqlClient->buildQuery(
['user' => ['login' => $this->username]],
[
'contributionsCollection' => [
'_parameters' => ['from' => $from, 'to' => $to],
'contributionCalendar' => [
'weeks' => [
'contributionDays' => ['date', 'contributionCount'],
],
],
],
]
)->getGraphQLQuery();
// GitHub's GraphQL API requires application/json — the bundle's built-in
// transport sends form_params, so we use Symfony HttpClient here instead.
$response = $this->client->request('POST', self::GRAPHQL_URL, [
// GitHub's GraphQL API requires application/json.
// request() doesn't block; the response is read in resolveFetch() so
// multiple providers' requests can be in flight at once.
return $this->client->request('POST', self::GRAPHQL_URL, [
'headers' => [
'Authorization' => "Bearer {$this->token}",
'Content-Type' => 'application/json',
],
'json' => ['query' => $query],
]);
}
$data = $response->toArray();
/**
* @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'])) {
throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors']));
@@ -2,11 +2,12 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Fetches the last 365 days of push/merge events from the GitLab REST API.
@@ -18,13 +19,17 @@ final class GitLabProvider implements ProviderInterface
{
use ProbeTrait;
private readonly string $baseUrl;
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $username,
private readonly string $token,
private readonly LoggerInterface $logger,
private readonly string $baseUrl = '',
) {}
string $baseUrl = '',
) {
$this->baseUrl = rtrim($baseUrl !== '' ? $baseUrl : 'https://gitlab.com', '/');
}
public function getName(): string
{
@@ -38,47 +43,59 @@ final class GitLabProvider implements ProviderInterface
public function ping(): void
{
$baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/');
$this->client->request('GET', "$baseUrl/api/v4/user", [
$this->client->request('GET', "$this->baseUrl/api/v4/user", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
])->getContent();
}
/**
* @return array<string, int> date (Y-m-d) => event count
* @return array{baseUrl: string, userId: int, after: 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(): array
{
$baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/');
$this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]);
$this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
$userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [
$userResponse = $this->client->request('GET', "$this->baseUrl/api/v4/users", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => ['username' => $this->username],
]);
$users = $userResponse->toArray();
if (empty($users)) {
throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $baseUrl");
throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $this->baseUrl");
}
$userId = $users[0]['id'];
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
$response = $this->client->request('GET', "$this->baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [
'after' => $after,
'per_page' => 100,
'page' => 1,
],
]);
return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response];
}
/**
* @return array<string, int> date (Y-m-d) => event count
*/
public function resolveFetch(mixed $handle): array
{
['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => $page, 'response' => $response] = $handle;
$result = [];
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
$page = 1;
do {
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [
'after' => $after,
'per_page' => 100,
'page' => $page,
],
]);
$events = $response->toArray();
foreach ($events as $event) {
@@ -87,6 +104,17 @@ final class GitLabProvider implements ProviderInterface
}
$page++;
if (count($events) === 100) {
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [
'after' => $after,
'per_page' => 100,
'page' => $page,
],
]);
}
} while (count($events) === 100);
$this->logger->info('GitLabProvider: fetched contributions', [
@@ -2,10 +2,11 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Fetches contribution data from the Gitea heatmap endpoint.
@@ -19,13 +20,17 @@ final class GiteaProvider implements ProviderInterface
{
use ProbeTrait;
private readonly string $baseUrl;
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $username,
private readonly string $token,
private readonly string $baseUrl,
string $baseUrl,
private readonly LoggerInterface $logger,
) {}
) {
$this->baseUrl = rtrim($baseUrl, '/');
}
public function getName(): string
{
@@ -39,27 +44,31 @@ final class GiteaProvider implements ProviderInterface
public function ping(): void
{
$baseUrl = rtrim($this->baseUrl, '/');
$this->client->request('GET', "$baseUrl/api/v1/user", [
$this->client->request('GET', "$this->baseUrl/api/v1/user", [
'headers' => ['Authorization' => "token {$this->token}"],
])->getContent();
}
public function startFetch(): ResponseInterface
{
$this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
return $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [
'headers' => ['Authorization' => "token {$this->token}"],
]);
}
/**
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function fetch(): array
public function resolveFetch(mixed $handle): array
{
$baseUrl = rtrim($this->baseUrl, '/');
$this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]);
$response = $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [
'headers' => ['Authorization' => "token {$this->token}"],
]);
$data = $response->toArray();
/** @var ResponseInterface $handle */
$data = $handle->toArray();
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp();
$result = [];
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
enum ProviderErrorCode: string
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
@@ -2,12 +2,15 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
interface ProviderInterface
{
/** Fire the HTTP request(s) without blocking; returns an opaque handle for resolveFetch(). */
public function startFetch(): mixed;
/** @return array<string, int> date (Y-m-d) => contribution count */
public function fetch(): array;
public function resolveFetch(mixed $handle): array;
public function isConfigured(): bool;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
final class ProviderStatus
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Provider;
enum ProviderStatusType: string
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Service\Renderer;
/**
* Renders a GitHub-style contribution heatmap as an inline SVG.
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\Contribution;
use App\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 App\Tests\Unit\Entity;
use App\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);
}
}
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\ContributionAggregator;
use App\Service\ProviderInterface;
use App\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -49,7 +49,7 @@ final class ContributionAggregatorTest extends TestCase
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(true);
$provider->method('fetch')->willReturn(['2024-01-01' => 3]);
$provider->method('resolveFetch')->willReturn(['2024-01-01' => 3]);
$aggregator = new ContributionAggregator([$provider], $this->logger);
@@ -63,11 +63,11 @@ final class ContributionAggregatorTest extends TestCase
{
$providerA = $this->createStub(ProviderInterface::class);
$providerA->method('isConfigured')->willReturn(true);
$providerA->method('fetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]);
$providerA->method('resolveFetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]);
$providerB = $this->createStub(ProviderInterface::class);
$providerB->method('isConfigured')->willReturn(true);
$providerB->method('fetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]);
$providerB->method('resolveFetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger);
@@ -77,15 +77,15 @@ final class ContributionAggregatorTest extends TestCase
}
#[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
{
$failing = $this->createStub(ProviderInterface::class);
$failing->method('isConfigured')->willReturn(true);
$failing->method('fetch')->willThrowException(new \RuntimeException('Network error'));
$failing->method('startFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('fetch')->willReturn(['2024-01-01' => 7]);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger);
@@ -95,14 +95,45 @@ final class ContributionAggregatorTest extends TestCase
}
#[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
{
$failing = $this->createStub(ProviderInterface::class);
$failing->method('isConfigured')->willReturn(true);
$failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger);
$result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result);
}
#[Test]
public function it_logs_a_warning_when_a_provider_start_fetch_throws(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(true);
$provider->method('fetch')->willThrowException(new \RuntimeException('fail'));
$provider->method('startFetch')->willThrowException(new \RuntimeException('fail'));
(new ContributionAggregator([$provider], $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('isConfigured')->willReturn(true);
$provider->method('resolveFetch')->willThrowException(new \RuntimeException('fail'));
(new ContributionAggregator([$provider], $logger))->aggregate();
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\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);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Provider;
use App\Service\GitHubProvider;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use IDCI\Bundle\GraphQLClientBundle\Query\GraphQLQuery;
use App\Service\Provider\GitHubProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -23,22 +20,9 @@ final class GitHubProviderTest extends TestCase
string $username = 'user',
string $token = 'token',
?HttpClientInterface $client = null,
?GraphQLApiClientRegistryInterface $registry = null,
): GitHubProvider {
if ($registry === null) {
$graphqlQuery = $this->createStub(GraphQLQuery::class);
$graphqlQuery->method('getGraphQLQuery')->willReturn('query {}');
$graphqlClient = $this->createStub(GraphQLApiClient::class);
$graphqlClient->method('buildQuery')->willReturn($graphqlQuery);
$registry = $this->createStub(GraphQLApiClientRegistryInterface::class);
$registry->method('get')->willReturn($graphqlClient);
}
return new GitHubProvider(
$client ?? $this->createStub(HttpClientInterface::class),
$registry,
$username,
$token,
$this->createStub(LoggerInterface::class),
@@ -96,7 +80,8 @@ final class GitHubProviderTest extends TestCase
]],
]));
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(4, $result['2024-06-10']);
$this->assertSame(2, $result['2024-06-11']);
@@ -112,7 +97,8 @@ final class GitHubProviderTest extends TestCase
]],
]));
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertArrayNotHasKey('2024-06-11', $result);
}
@@ -130,7 +116,8 @@ final class GitHubProviderTest extends TestCase
$this->expectException(ServiceUnavailableHttpException::class);
$this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
}
#[Test]
@@ -139,7 +126,8 @@ final class GitHubProviderTest extends TestCase
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubGraphqlResponse([]));
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result);
}
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Provider;
use App\Service\GitLabProvider;
use App\Service\Provider\GitLabProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -71,7 +71,8 @@ final class GitLabProviderTest extends TestCase
$this->expectException(NotFoundHttpException::class);
$this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
}
#[Test]
@@ -92,7 +93,8 @@ final class GitLabProviderTest extends TestCase
}
);
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(2, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']);
@@ -119,7 +121,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, $callCount);
$this->assertSame(100, $result['2024-06-10']);
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Provider;
use App\Service\GiteaProvider;
use App\Service\Provider\GiteaProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -78,7 +78,8 @@ final class GiteaProviderTest extends TestCase
['timestamp' => $now, 'contributions' => 5],
]));
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(5, $result[date('Y-m-d', $now)]);
}
@@ -93,7 +94,8 @@ final class GiteaProviderTest extends TestCase
['timestamp' => $old, 'contributions' => 3],
]));
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result);
}
@@ -104,7 +106,8 @@ final class GiteaProviderTest extends TestCase
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([]));
$result = $this->makeProvider(client: $client)->fetch();
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result);
}
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Provider;
use App\Service\ProbeTrait;
use App\Service\ProviderErrorCode;
use App\Service\ProviderInterface;
use App\Service\ProviderStatusType;
use App\Service\Provider\ProbeTrait;
use App\Service\Provider\ProviderErrorCode;
use App\Service\Provider\ProviderInterface;
use App\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -31,7 +31,8 @@ final class ProbeTraitTest extends TestCase
public function isConfigured(): bool { return $this->configured; }
public function getName(): string { return 'test'; }
public function ping(): void { ($this->ping)(); }
public function fetch(): array { return []; }
public function startFetch(): mixed { return null; }
public function resolveFetch(mixed $handle): array { return []; }
};
}
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Provider;
use App\Service\ProviderErrorCode;
use App\Service\ProviderHealthChecker;
use App\Service\ProviderInterface;
use App\Service\ProviderStatus;
use App\Service\ProviderStatusType;
use App\Service\Provider\ProviderErrorCode;
use App\Service\Provider\ProviderHealthChecker;
use App\Service\Provider\ProviderInterface;
use App\Service\Provider\ProviderStatus;
use App\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Provider;
use App\Service\ProviderErrorCode;
use App\Service\ProviderStatus;
use App\Service\ProviderStatusType;
use App\Service\Provider\ProviderErrorCode;
use App\Service\Provider\ProviderStatus;
use App\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Tests\Unit\Service;
namespace App\Tests\Unit\Service\Renderer;
use App\Service\SvgRenderer;
use App\Service\Renderer\SvgRenderer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;