Author SHA1 Message Date
haylan f3491ef4a6 fix(ci): downgrade actions/upload-artifact and actions/download-artifact to v3 for compatibility
Tests / build-dev (pull_request) Successful in 1m14s
Tests / test (pull_request) Failing after 38s
Tests / build-prod (pull_request) Successful in 4m2s
2026-07-17 12:41:35 +02:00
haylan d6a4f7c41f feat(ci): update Gitea workflow to include registry login and cache handling adjustments
Tests / build-dev (pull_request) Failing after 2m40s
Tests / build-prod (pull_request) Has been skipped
Tests / test (pull_request) Has been skipped
2026-07-17 12:37:12 +02:00
haylan 211edc4538 refactor(ci): reorganize test workflow to streamline Docker image handling
Tests / build-dev (pull_request) Failing after 3m28s
Tests / build-prod (pull_request) Has been skipped
Tests / test (pull_request) Has been skipped
2026-07-17 12:27:41 +02:00
haylan 14b325b3bf refactor(ci): migrate PHP setup to Docker for testing workflow
Tests / test (pull_request) Has been cancelled
Tests / docker-build (pull_request) Has been cancelled
2026-07-17 12:19:39 +02:00
haylan d15e25abf3 Merge branch 'main' into patch-2-gitea-registry
Tests / test (pull_request) Has been cancelled
Tests / docker-build (pull_request) Has been cancelled
2026-07-17 10:09:04 +00:00
haylan 53186e9a35 feat(ci): add GitHub Actions workflow for testing and Docker builds
Tests / test (pull_request) Has been cancelled
Tests / docker-build (pull_request) Has been cancelled
2026-07-17 12:07:31 +02:00
haylan 7688fd77c9 Merge pull request 'chore(docker): remove redundant composer dump-env command in build stage' (#16) from patch-2-gitea-registry into main
Reviewed-on: #16
2026-07-13 10:23:14 +00:00
haylan 6e79337a60 chore(docker): remove redundant composer dump-env command in build stage 2026-07-13 12:19:06 +02:00
haylan 68424aa28b Merge pull request 'chore(ci/cd): replaced registry url.' (#15) from patch-2-gitea-registry into main
Reviewed-on: #15
2026-07-13 10:11:00 +00:00
haylan fdc7fa700d chore(ci/cd): replaced registry url. 2026-07-13 12:10:38 +02:00
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
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
haylan 2f66c65b30 Merge pull request 'feat: FrankenPHP runtime, per-provider health probes, and ContributionAggregator' (#2) from feat/frankenphp into main
Reviewed-on: #2
2026-05-30 14:32:10 +02:00
haylanandClaude Sonnet 4.6 38312f549c docs: add CHANGELOG for 0.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:27:58 +02:00
haylanandClaude Sonnet 4.6 a8d5f205db docs: fix typos and reformat tables in CLAUDE.md
Fix "Prever" typo, add Windows WSL test runner command, and align
Markdown table columns for readability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:14:38 +02:00
haylanandClaude Sonnet 4.6 85428826a0 test(provider): add unit tests for probe infrastructure and all providers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:14:06 +02:00
haylanandClaude Sonnet 4.6 61b7735afc refactor(renderer): add strict_types declaration and make class final
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:13:51 +02:00
haylanandClaude Sonnet 4.6 c70d96c3aa feat(health): make /health report per-provider probe status
Introduce ProviderHealthChecker which probes each configured provider
via AutowireIterator. Wire it into GraphController so /health returns
detailed per-provider status and responds 503 when any provider is
in a degraded state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:13:24 +02:00
haylanandClaude Sonnet 4.6 d3b9463c57 feat(provider): implement ping and probe on GitHub, GitLab, and Gitea
Add getName() and ping() to each provider and wire ProbeTrait. Make
all classes final, add strict_types declarations, and replace generic
RuntimeException with typed HTTP exceptions so probe() can classify
auth failures and unreachable endpoints correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:13:14 +02:00
haylanandClaude Sonnet 4.6 71bfb38028 feat(provider): add health-probe contract and supporting value objects
Add getName(), ping(), and probe() to ProviderInterface. Introduce
ProbeTrait to classify HTTP/transport exceptions into typed error codes,
ProviderStatus as the result value object, and ProviderStatusType /
ProviderErrorCode as backing enums.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:13:07 +02:00
haylanandClaude Sonnet 4.6 225c614057 test(service): refactor SvgRendererTest to follow PHPUnit best practices
- Replace setUp() factory with direct instantiation (zero-param constructor)
- Add AAA blank-line separators to all test methods
- Consolidate theme + fallback tests behind a DataProvider
- Consolidate day-of-week label tests behind a DataProvider
- Split multi-assertion tests to one assertion per test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:58:23 +02:00
haylanandClaude Sonnet 4.6 c205fed14b refactor(controller): extract contribution aggregation into dedicated service
Move fetchAllContributions and merge logic from GraphController into a new
ContributionAggregator service. Replace deprecated TaggedIterator with
AutowireIterator throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:58:17 +02:00
haylanandClaude Sonnet 4.6 3e3a6752af chore(dev): remove broken auto-run PHPUnit hook
The PostToolUse hook called php bin/phpunit directly, which does not
work inside the Docker-based dev setup. Remove it; tests should be
run via docker compose exec graph vendor/bin/phpunit as documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:39:39 +02:00
haylanandClaude Sonnet 4.6 5b07eae672 docs: update installation guide to use pre-built image
Replace the clone-and-build workflow with a docker-compose snippet
that pulls the image directly from the Gitea container registry.
Add semver tag reference and rename steps to match the new flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:39:33 +02:00
haylanandClaude Sonnet 4.6 235e63bfc0 fix(config): add default empty values for optional env vars
Declare empty-string defaults for all optional env vars as Symfony
parameters so the container compiles without errors when GITHUB_USER,
GITLAB_TOKEN, GITEA_URL, etc. are not set in the environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:39:29 +02:00
haylanandClaude Sonnet 4.6 92380e534a feat(docker): migrate base image from php-cli to FrankenPHP Alpine
Replace php:8.4-cli-alpine3.21 with dunglas/frankenphp:1-php8.4-alpine.
No Go toolchain required — the official image ships as a pre-compiled
Alpine binary.

- Drop docker-php-ext-install opcache (FrankenPHP enables it by default)
- Both dev and final stages run frankenphp run --config /etc/caddy/Caddyfile
- Dev stage bakes in Caddyfile.dev (no worker mode, Xdebug compatible)
- Final stage bakes in production Caddyfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:38:38 +02:00
haylanandClaude Sonnet 4.6 4654a287a8 feat(app): add FrankenPHP worker script for Symfony
Boot the Symfony kernel once at startup, then loop with
frankenphp_handle_request() to handle every incoming request without
re-bootstrapping the framework. Supports MAX_REQUESTS env var for
graceful worker restarts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:38:27 +02:00
haylanandClaude Sonnet 4.6 2427c07f4c feat(docker): add FrankenPHP Caddy configuration
Add production and dev Caddyfiles for FrankenPHP:
- Caddyfile: HTTP-only on :8080, auto_https off, worker mode
- Caddyfile.dev: same but without worker directive so Xdebug
  step-debugging remains functional; Caddy debug logging enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:38:23 +02:00
haylan 7c1b893e09 chore: update docker image to php 8.4 2026-05-29 23:20:53 +02:00
haylan 4993fdca00 Update .env 2026-05-29 23:04:39 +02:00
haylan c4803ea5cb fix: fixed labeling to make contaienr apper correctly in the repo 2026-05-29 22:43:53 +02:00
haylan 19bfa4cf28 Update README.md 2026-05-29 22:37:25 +02:00
haylan e70d035672 feat: change to use token 2026-05-29 19:42:10 +02:00
haylan d57320cb77 fix: changed pipline to use workflow token instead self made one 2026-05-29 19:32:36 +02:00
haylan 5feae97dd3 feat: fix manuel pipline. to create a release tag or replace exisiting release 2026-05-29 19:20:32 +02:00
68 changed files with 8240 additions and 628 deletions
+1 -10
View File
@@ -1,10 +1 @@
{ {}
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"command": "php bin/phpunit 2>&1 | tail -20"
}
]
}
}
+32 -1
View File
@@ -1,6 +1,37 @@
# Build descriptors (not application code)
Dockerfile*
# Version control
.git .git
.gitea .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/ 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
.env.*.local .env.*.local
docker-compose.override.yml
+5 -1
View File
@@ -1,5 +1,5 @@
APP_ENV=dev APP_ENV=dev
APP_DEBUG=0 APP_DEBUG=1
APP_SECRET=changeme_replace_with_random_32char_string APP_SECRET=changeme_replace_with_random_32char_string
# Comma-separated list of allowed hostnames. Leave empty to allow all hosts. # Comma-separated list of allowed hostnames. Leave empty to allow all hosts.
@@ -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
+44 -19
View File
@@ -1,10 +1,10 @@
# Builds and pushes a multi-arch Docker image to the Gitea container registry # Builds and pushes a multi-arch Docker image to the Gitea container registry.
# whenever a semver tag (v*.*.*) is pushed. # Triggered manually via workflow_dispatch — enter an existing semver tag (e.g. 1.2.3)
# in the "Release tag" input. The workflow will fail early if the tag does not exist.
# #
# One-time setup required: # Requires a repository secret REGISTRY_TOKEN — a Gitea PAT with write:package scope.
# 1. Create a Gitea token with "package:write" scope. # Create it at: Settings → Applications → Generate Token (scope: write:package)
# 2. Add it as a repository secret named GITEA_TOKEN # Then add it: Repository → Settings → Secrets → Actions → REGISTRY_TOKEN
# (Repository → Settings → Secrets → Actions).
# #
# After a successful run the image is available at: # After a successful run the image is available at:
# <your-gitea-host>/<owner>/<repo>:<version> # <your-gitea-host>/<owner>/<repo>:<version>
@@ -12,35 +12,48 @@
name: Docker Publish name: Docker Publish
on: on:
push: workflow_dispatch:
tags: inputs:
- 'v*.*.*' tag:
description: "Release tag (semver, e.g. 1.2.3)"
required: true
type: string
env:
REGISTRY: git.arthurerlich.de
jobs: jobs:
build-push: build-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
# Strip the protocol from the server URL to get the registry hostname. - name: Validate tag exists
# e.g. https://gitea.example.com → gitea.example.com
- name: Derive registry hostname
run: | run: |
echo "REGISTRY=$(echo '${{ gitea.server_url }}' | sed 's|https://||;s|http://||')" >> $GITHUB_ENV if ! git rev-parse "refs/tags/${{ inputs.tag }}" >/dev/null 2>&1; then
echo "Error: tag '${{ inputs.tag }}' does not exist in this repository."
exit 1
fi
git checkout "refs/tags/${{ inputs.tag }}"
# Generates OCI-compliant tags and labels from the git tag. # Generates OCI-compliant tags and labels from the provided release tag.
# v1.2.3 → image tags: 1.2.3 / 1.2 / 1 # 1.2.3 → image tags: 1.2.3 / 1.2 / 1
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ gitea.repository }} images: ${{ env.REGISTRY }}/${{ gitea.repository }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}},value=${{ inputs.tag }}
type=semver,pattern={{major}}.{{minor}} labels: |
type=semver,pattern={{major}} org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
# QEMU enables emulation of arm64 on the amd64 runner. # QEMU enables emulation of arm64 on the amd64 runner.
- name: Set up QEMU - name: Set up QEMU
@@ -55,12 +68,24 @@ jobs:
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }} 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 - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
target: final
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+87
View File
@@ -0,0 +1,87 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: git.arthurerlich.de
jobs:
build-dev:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# type=gha depends on act_runner's internal cache proxy, which is unreachable
# from job containers on this runner (dial tcp ... i/o timeout). Use the
# registry cache backend instead, same as docker-publish.yml.
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build dev image (PHP 8.4 + composer deps)
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.dev
target: dev
outputs: type=docker,dest=/tmp/graph-dev.tar
tags: graph-dev:ci
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache-dev
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache-dev,mode=max
- name: Upload dev image
uses: actions/upload-artifact@v3
with:
name: graph-dev-image
path: /tmp/graph-dev.tar
retention-days: 1
build-prod:
needs: build-dev
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build prod image (final target)
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
target: final
push: false
test:
needs: build-dev
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download dev image
uses: actions/download-artifact@v3
with:
name: graph-dev-image
path: /tmp
- name: Load dev image
run: docker load --input /tmp/graph-dev.tar
- name: Run PHPUnit
run: docker run --rm graph-dev:ci vendor/bin/phpunit --testdox
- name: Run PHPStan
run: docker run --rm graph-dev:ci composer phpstan
-1
View File
@@ -3,5 +3,4 @@
/vendor/ /vendor/
/var/ /var/
/public/bundles/ /public/bundles/
composer.lock
/.phpunit.cache /.phpunit.cache
+85
View File
@@ -0,0 +1,85 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.0] - 2026-07-12
### Added
- **Contribution history store (SQLite)** — `ContributionStore` persists each provider's daily counts in `var/data/contributions.db`; `ContributionAggregator` now fetches only the window since each provider's last stored date (plus a 3-day trailing overlap for late corrections), merges the result into the store, and prunes rows older than `CONTRIBUTIONS_RETENTION_DAYS`
- **`graph:contributions:refetch` console command** — forces a full or explicit-range re-fetch of contribution history, chunked into ≤365-day windows, for one, several, or all configured providers
- **Concurrent, non-blocking provider fetches** — `ProviderInterface::fetch()` split into `startFetch()`/`resolveFetch()` so GitHub, GitLab, and Gitea requests are in flight simultaneously instead of one after another
- **Bounded fetch windows** — `startFetch()`/`resolveFetch()` accept optional `$since`/`$until` bounds, used by both the incremental aggregator and the refetch command
- **PHPStan static analysis** — level 8 over `src/` and `tests/`, run via `composer phpstan`
- `Contribution` and `ContributionCollection` typed entities
- `CONTRIBUTIONS_RETENTION_DAYS` environment variable
### Changed
- **Namespace renamed from `App` to `GitContributionGraph`** across the codebase, Composer autoloading, and routing config
- Removed the Guzzle and GraphQL client bundles — GitHub requests now go through Symfony's `HttpClientInterface` directly, the same as GitLab and Gitea
- HTTP client requests are capped (10s connect/first-byte timeout, 15s max duration) so a slow provider can no longer stall the render path
- `docker-compose.yml` and `docker-compose.prod.yml` mount a `data` volume for the SQLite store and pass through `CONTRIBUTIONS_RETENTION_DAYS`
- Dev Docker Compose override targets the `dev` build stage explicitly and mounts `vendor` read-write instead of as an anonymous volume
### Fixed
- `ContributionCollection`'s variadic constructor now re-indexes its arguments with `array_values()`, avoiding non-sequential keys that didn't match its declared property type
- `SvgRenderer` coerces `strtotime()` results to `int` before formatting dates
- `docker-compose.prod.yml` was missing the `data` volume and `CONTRIBUTIONS_RETENTION_DAYS` env var present in `docker-compose.yml`, so contribution history would not survive a container recreate on that deployment path
- `composer phpstan` no longer crashes with an out-of-memory error under the default 128M limit
### Documentation
- README and `CLAUDE.md` updated to describe the SQLite store, incremental fetch, refetch command, and PHPStan setup; corrected several stale examples (test path, `/health` sample response, Docker volume mounts)
- Added PHPDoc to the remaining public methods across `src/`
### Tests
- Added tests for `ContributionStore`, `Contribution`, `ContributionCollection`, and `RefetchContributionsCommand`
- Extended provider and aggregator tests to cover `$since`/`$until` windowing, concurrent-fetch failure modes, and store interactions
## [0.1.0] - 2026-05-30
### Added
- **FrankenPHP runtime** — base image migrated from `php-cli` to FrankenPHP Alpine for better performance and built-in web server support
- **FrankenPHP worker mode** — Symfony worker script (`public/worker.php`) with request reuse and graceful reload
- **Caddy configuration** — `docker/frankenphp/Caddyfile` and `Caddyfile.dev` for production and development setups
- **Provider health probes** — `ProviderInterface` extended with `ping()` and `probe()` methods; `ProbeTrait` provides the shared implementation
- **Per-provider status on `/health`** — the health endpoint now reports `ok`, `degraded`, or `down` for each configured provider (GitHub, GitLab, Gitea), along with an error code and latency
- **Supporting value objects** — `ProviderStatus`, `ProviderStatusType`, `ProviderErrorCode`, and `ProviderHealthChecker` service
- **`ContributionAggregator` service** — contribution merging logic extracted from `GraphController` into a dedicated, testable service
### Fixed
- Optional environment variables (`GITLAB_URL`, `ALLOWED_HOSTS`, etc.) now have explicit empty default values in `docker-compose.yml`, preventing Docker Compose warnings on startup
### Changed
- `SvgRenderer` is now `final` and declares `strict_types=1`
### Tests
- Unit tests added for `ProbeTrait`, `ProviderHealthChecker`, `ProviderStatus`, `ContributionAggregator`, and all three provider probes (`GitHubProvider`, `GitLabProvider`, `GiteaProvider`)
- `SvgRendererTest` refactored to follow project PHPUnit conventions (`#[Test]` attribute, `it_` prefix, Arrange/Act/Assert structure)
## [0.0.1] - 2025-01-01
### Added
- Initial release — GitHub, GitLab, and Gitea contribution graph as a self-hosted SVG endpoint
- Light and dark theme support
- Filesystem cache with 1-hour TTL
- Docker multi-stage build (`base → deps → build → final`)
- Basic `/health` endpoint
[Unreleased]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.2.0...HEAD
[0.2.0]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.1.0...0.2.0
[0.1.0]: https://github.com/ArthurErlich/git-contribution-graph/compare/0.0.1...0.1.0
[0.0.1]: https://github.com/ArthurErlich/git-contribution-graph/releases/tag/0.0.1
+66 -12
View File
@@ -77,14 +77,15 @@ Run vendor/bin/phpunit after each change to confirm tests stay green.
**Common anti-patterns** **Common anti-patterns**
| Wrong prompt | Why it breaks TDD | Correct prompt | | Wrong prompt | Why it breaks TDD | Correct prompt |
|---|---|---| | ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------ |
| "Write tests for this feature" | Claude implements first, then fits tests to it | "Write **failing** tests for [feature]. Stop before any implementation." | | "Write tests for this feature" | Claude implements first, then fits tests to it | "Write **failing** tests for [feature]. Stop before any implementation." |
| "Add tests and implementation" | Loses the design feedback of failing tests | Two separate prompts: Red, then Green | | "Add tests and implementation" | Loses the design feedback of failing tests | Two separate prompts: Red, then Green |
| "Make the tests pass" | Encourages skipping to a green state | "Implement the minimum to make the failing tests pass." | | "Make the tests pass" | Encourages skipping to a green state | "Implement the minimum to make the failing tests pass." |
| Combining Red + Green in one request | No failing baseline | Always separate the two phases | | Combining Red + Green in one request | No failing baseline | Always separate the two phases |
### Running tests ### Running tests
Prever to use the tests in `docker compose exec graph`
Prefer to use the tests in `docker compose exec graph`
```bash ```bash
# Run full suite # Run full suite
@@ -98,6 +99,15 @@ 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):
```powershell
wsl -e bash -c "cd /mnt/g/_DEV/repos/git-contribution-graph && docker compose exec graph vendor/bin/phpunit --testdox 2>&1"
``` ```
### Auto-run hook ### Auto-run hook
@@ -117,6 +127,41 @@ Add to `.claude/settings.json` to run PHPUnit automatically after every file edi
} }
``` ```
## Gitea Workflows
Workflow files live in [.gitea/workflows/](.gitea/workflows/). This project uses Gitea Actions (GitHub Actions-compatible syntax).
**When editing workflow files:**
- Use `gitea.*` context variables, not `github.*` — e.g. `${{ gitea.server_url }}`, `${{ gitea.actor }}`, `${{ gitea.repository }}`
- `GITHUB_ENV` and `GITHUB_OUTPUT` are still the correct env file names (Gitea Actions re-uses these)
- Secrets are set under Repository → Settings → Secrets → Actions
- The registry hostname must be derived from `gitea.server_url` (strip the protocol prefix)
- Triggers use standard `on:` syntax; `tags: '*.*.*'` matches semver pushes without a `v` prefix
- Do not use `cache-from/cache-to: type=gha` — on this instance's act_runner (v2.0.0), the internal GitHub Actions-cache-compatible proxy is not reachable from job containers (`dial tcp <ip>:<port>: i/o timeout`), a known act_runner docker-executor networking limitation, not a workflow bug. Use `type=registry` instead (push cache blobs to `${{ env.REGISTRY }}/<repo>:buildcache*` — see both `test.yml` and `docker-publish.yml`); it requires a registry login step even for jobs that don't push the final image.
**Current workflows:**
| File | Trigger | Purpose |
| -------------------- | ---------------- | --------------------------------------------------------- |
| `docker-publish.yml` | Push tag `*.*.*` | Build & push multi-arch image to Gitea container registry |
| `test.yml` | Push/PR to `main`| Build dev+prod images, run PHPUnit and PHPStan |
### Release testing with `act`
Before tagging a release (anything that would trigger `docker-publish.yml`), run `test.yml` locally with [`act`](https://github.com/nektos/act) to catch pipeline failures before pushing. `test.yml` uses plain GitHub Actions syntax (no `gitea.*` contexts), so it runs under `act` unmodified.
```bash
act -j test \
-P ubuntu-latest=catthehacker/ubuntu:act-latest \
--artifact-server-path /tmp/act-artifacts
```
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` — default act runner image lacks a Docker CLI, which the `test` job needs for its nested `docker run` steps
- `--artifact-server-path` — required for the `upload-artifact`/`download-artifact` steps to work; without it they silently no-op
**If `act` is not installed:** warn the user it's missing and how to install it (`sudo pacman -S act`, or see the repo above) — do not install it yourself or force the check. `docker-publish.yml` is out of scope for `act` (it needs real `gitea.*` context and a live registry secret); don't try to run it locally.
## Docker ## Docker
### Development ### Development
@@ -165,20 +210,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
``` ```
@@ -187,17 +236,22 @@ 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
| Variable | Required | Notes | | Variable | Required | Notes |
|---|---|---| | ------------------------------------------ | ---------- | -------------------------------------------- |
| `APP_SECRET` | Yes | 32+ char random string | | `APP_SECRET` | Yes | 32+ char random string |
| `GITHUB_USER` / `GITHUB_TOKEN` | For GitHub | Token scope: `read:user` | | `GITHUB_USER` / `GITHUB_TOKEN` | For GitHub | Token scope: `read:user` |
| `GITLAB_USER` / `GITLAB_TOKEN` | For GitLab | Token scopes: `read_user`, `read_api` | | `GITLAB_USER` / `GITLAB_TOKEN` | For GitLab | Token scopes: `read_user`, `read_api` |
| `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.
+28 -26
View File
@@ -1,10 +1,16 @@
FROM php:8.3-cli-alpine AS base #syntax=docker/dockerfile:1
RUN apk add --no-cache \ FROM dunglas/frankenphp:1-php8.4-alpine AS base
curl \
icu-libs \ RUN apk add --no-cache icu-dev libzip-dev \
libzip \ && docker-php-ext-install -j$(nproc) intl opcache zip \
&& docker-php-ext-install opcache && 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 WORKDIR /app
@@ -22,36 +28,32 @@ RUN composer install \
# ── build stage (generate optimised classmap with source present) ────────────── # ── build stage (generate optimised classmap with source present) ──────────────
FROM deps AS build FROM deps AS build
COPY . . COPY . .
RUN composer dump-autoload --optimize --no-dev --no-interaction RUN composer dump-autoload --classmap-authoritative --no-dev --no-interaction && \
mkdir -p var/cache var/log && \
# ── dev stage (all deps + Xdebug, source is mounted at runtime) ─────────────── APP_ENV=prod APP_SECRET=placeholder php bin/console cache:warmup --no-debug
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 composer.json composer.lock* ./
RUN composer install --no-scripts --no-interaction --prefer-dist
EXPOSE 8080
ENV APP_ENV=dev APP_DEBUG=1
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"]
# ── final (prod) stage — no composer binary ──────────────────────────────────── # ── final (prod) stage — no composer binary ────────────────────────────────────
FROM base AS final FROM base AS final
RUN addgroup -S app && adduser -S -G app app RUN addgroup -S app && adduser -S -G app app
COPY --from=build /app/vendor /app/vendor COPY --link --from=build /app/vendor /app/vendor
COPY . . 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 \ RUN chmod +x bin/console && \
&& chown -R app:app /app mkdir -p var/cache/prod/pools var/log var/data /config/caddy /data/caddy && \
chown -R app:app /app /config /data
USER app USER app
EXPOSE 8080 EXPOSE 8080
ENV APP_ENV=prod APP_DEBUG=0 ENV APP_ENV=prod APP_DEBUG=0
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"] CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
+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"]
+102 -24
View File
@@ -6,7 +6,7 @@ A self-hosted Symfony service that merges contribution data from **GitHub**, **G
https://your-host/graph.svg?theme=dark https://your-host/graph.svg?theme=dark
``` ```
![Example graph](https://gitgraph.arthurerlich/graph.svg) ![Example graph](https://gitgraph.arthurerlich.de/graph.svg)
--- ---
@@ -27,15 +27,40 @@ https://your-host/graph.svg?theme=dark
- Docker + Docker Compose - Docker + Docker Compose
### 1. Clone and configure ### 1. Create a `docker-compose.yml`
```bash Use the pre-built image
git clone https://git.arthurerlich.de/haylan/git-contribution-graph.git
cd git-contribution-graph ```yaml
cp .env .env.local services:
graph:
image: git.arthurerlich.de/haylan/git-contribution-graph:latest
ports:
- "8080:8080"
env_file:
- .env.local
volumes:
- cache:/app/var/cache/prod/pools
- logs:/app/var/log
- data:/app/var/data
volumes:
cache:
logs:
data:
``` ```
Edit `.env.local` with your credentials: The image is published to the Gitea container registry. Pull it manually with:
```bash
docker pull git.arthurerlich.de/haylan/git-contribution-graph:latest
```
Semver tags are published (`0`, `0.0`, `0.0.1`) alongside `latest` — see the [container registry](https://git.arthurerlich.de/haylan/-/packages/container/git-contribution-graph/latest) for all available tags.
### 2. Configure
Create a `.env.local` file next to your `docker-compose.yml`:
```dotenv ```dotenv
APP_SECRET=<generate with: openssl rand -hex 16> APP_SECRET=<generate with: openssl rand -hex 16>
@@ -56,24 +81,39 @@ 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.
### 2. Start ### 3. Start
```bash ```bash
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.
### 3. Health check ### 4. Verify
```bash
curl http://localhost:8080/health
``` ```
GET /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
@@ -153,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
@@ -173,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
``` ```
@@ -209,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
+5012
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
+33 -16
View File
@@ -1,31 +1,48 @@
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(CONTRIBUTIONS_RETENTION_DAYS): "0"
services: services:
_defaults: _defaults:
autowire: true autowire: true
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
@@ -3,10 +3,12 @@
services: services:
graph: graph:
build: build:
dockerfile: Dockerfile.dev
target: 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"
+35
View File
@@ -0,0 +1,35 @@
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:-}"
CONTRIBUTIONS_RETENTION_DAYS: "${CONTRIBUTIONS_RETENTION_DAYS:-0}"
volumes:
- cache:/app/var/cache/prod/pools
- logs:/app/var/log
- data:/app/var/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
cache:
logs:
data:
+5 -1
View File
@@ -20,15 +20,19 @@ 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 - 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
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s
volumes: volumes:
cache: cache:
logs: logs:
data:
+13
View File
@@ -0,0 +1,13 @@
{
admin off
auto_https off
frankenphp {
worker /app/public/worker.php
}
}
:8080 {
root * /app/public
php_server
}
+12
View File
@@ -0,0 +1,12 @@
{
admin off
auto_https off
debug
frankenphp
}
:8080 {
root * /app/public
php_server
}
+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
+5
View File
@@ -0,0 +1,5 @@
parameters:
level: 8
paths:
- src
- tests
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

+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';
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use GitContributionGraph\Kernel;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload.php';
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'prod', (bool) ($_SERVER['APP_DEBUG'] ?? false));
$kernel->boot();
$handler = static function () use ($kernel): void {
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
$requestCount = 0;
while (\frankenphp_handle_request($handler)) {
if ($maxRequests > 0 && ++$requestCount >= $maxRequests) {
break;
}
}
$kernel->shutdown();
+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+)"
}
]
}
+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;
}
}
+36 -41
View File
@@ -1,12 +1,14 @@
<?php <?php
namespace App\Controller; declare(strict_types=1);
use App\Service\ProviderInterface; namespace GitContributionGraph\Controller;
use App\Service\SvgRenderer;
use GitContributionGraph\Service\ContributionAggregator;
use GitContributionGraph\Service\Provider\ProviderHealthChecker;
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\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -14,17 +16,17 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
class GraphController final class GraphController
{ {
/** @var list<string> */ /** @var list<string> */
private readonly array $allowedHosts; private readonly array $allowedHosts;
public function __construct( public function __construct(
#[TaggedIterator('app.provider')] private readonly ContributionAggregator $aggregator,
private readonly iterable $providers,
private readonly SvgRenderer $renderer, private readonly SvgRenderer $renderer,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly ProviderHealthChecker $healthChecker,
#[Autowire(env: 'ALLOWED_HOSTS')] #[Autowire(env: 'ALLOWED_HOSTS')]
string $allowedHosts = '', string $allowedHosts = '',
) { ) {
@@ -32,8 +34,12 @@ 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
@@ -51,8 +57,9 @@ 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->fetchAllContributions(), $theme); return $this->renderer->render($this->aggregator->aggregate(), $theme);
}); });
$this->logger->debug('GraphController: cache ' . ($cacheMiss ? 'miss' : 'hit'), ['theme' => $theme]); $this->logger->debug('GraphController: cache ' . ($cacheMiss ? 'miss' : 'hit'), ['theme' => $theme]);
@@ -63,6 +70,12 @@ 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
{ {
@@ -72,40 +85,22 @@ 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
{ {
return new Response('{"status":"ok"}', 200, ['Content-Type' => 'application/json']); $result = $this->healthChecker->check();
} $statusCode = $result['status'] === 'degraded' ? 503 : 200;
/** @return array<string, int> */ return new Response(
private function fetchAllContributions(): array json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
{ $statusCode,
$contributions = []; ['Content-Type' => 'application/json'],
);
/** @var ProviderInterface $provider */
foreach ($this->providers as $provider) {
if (!$provider->isConfigured()) {
continue;
}
try {
$contributions = $this->merge($contributions, $provider->fetch());
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
return $contributions;
}
/** @param array<string, int> $base @param array<string, int> $new @return array<string, int> */
private function merge(array $base, array $new): array
{
foreach ($new as $date => $count) {
$base[$date] = ($base[$date] ?? 0) + $count;
}
return $base;
} }
} }
+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;
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service;
use GitContributionGraph\Service\Provider\ProviderInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ContributionAggregator
{
private const RENDER_WINDOW_DAYS = 371;
private const OVERLAP_DAYS = 3;
/** @param iterable<ProviderInterface> $providers */
public function __construct(
#[AutowireIterator('app.provider')]
private readonly iterable $providers,
private readonly ContributionStore $store,
private readonly LoggerInterface $logger,
) {}
/**
* 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
{
$configured = [];
/** @var ProviderInterface $provider */
foreach ($this->providers as $provider) {
if ($provider->isConfigured()) {
$configured[] = $provider;
}
}
$pending = [];
foreach ($configured as $provider) {
$latest = $this->store->latestDate($provider->getName());
$since = $latest !== null
? (new \DateTimeImmutable('@' . $latest))->modify('-' . self::OVERLAP_DAYS . ' days')
: null;
try {
$pending[] = [$provider, $provider->startFetch($since)];
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
foreach ($pending as [$provider, $handle]) {
try {
$fresh = $provider->resolveFetch($handle);
$dateCounts = [];
foreach ($fresh as $date => $count) {
$dateCounts[(new \DateTimeImmutable($date))->getTimestamp()] = $count;
}
$this->store->merge($provider->getName(), $dateCounts);
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
$contributions = [];
foreach ($configured as $provider) {
foreach ($this->store->all($provider->getName(), sinceDays: self::RENDER_WINDOW_DAYS) as $contribution) {
$date = (new \DateTimeImmutable('@' . $contribution->date))->format('Y-m-d');
$contributions[$date] = ($contributions[$date] ?? 0) + $contribution->count;
}
}
$this->store->prune();
return $contributions;
}
}
+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]);
}
}
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace App\Service;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Fetches the last 365 days of contributions from the GitHub GraphQL API.
*
* Required token scopes: read:user
*/
class GitHubProvider implements ProviderInterface
{
private const GRAPHQL_URL = 'https://api.github.com/graphql';
public function __construct(
private readonly HttpClientInterface $client,
private readonly GraphQLApiClientRegistryInterface $registry,
private readonly string $username,
private readonly string $token,
private readonly LoggerInterface $logger,
) {}
public function isConfigured(): bool
{
return $this->username !== '' && $this->token !== '';
}
/**
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function fetch(): array
{
$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 = $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, [
'headers' => [
'Authorization' => "Bearer {$this->token}",
'Content-Type' => 'application/json',
],
'json' => ['query' => $query],
]);
$data = $response->toArray();
if (isset($data['errors'])) {
throw new \RuntimeException('GitHub GraphQL error: ' . json_encode($data['errors']));
}
$result = [];
$weeks = $data['data']['user']['contributionsCollection']['contributionCalendar']['weeks'] ?? [];
foreach ($weeks as $week) {
foreach ($week['contributionDays'] as $day) {
if ($day['contributionCount'] > 0) {
$result[$day['date']] = $day['contributionCount'];
}
}
}
$this->logger->info('GitHubProvider: fetched contributions', [
'user' => $this->username,
'days' => count($result),
'total' => array_sum($result),
]);
return $result;
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Fetches the last 365 days of push/merge events from the GitLab REST API.
*
* Required token scopes: read_user, read_api
* Works with both gitlab.com and self-hosted instances.
*/
class GitLabProvider implements ProviderInterface
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $username,
private readonly string $token,
private readonly LoggerInterface $logger,
private readonly string $baseUrl = '',
) {}
public function isConfigured(): bool
{
return $this->username !== '' && $this->token !== '';
}
/**
* @return array<string, int> date (Y-m-d) => event count
*/
public function fetch(): array
{
$baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/');
$this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]);
// Resolve numeric user ID from username
$userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => ['username' => $this->username],
]);
$users = $userResponse->toArray();
if (empty($users)) {
throw new \RuntimeException("GitLab: user '{$this->username}' not found on $baseUrl");
}
$userId = $users[0]['id'];
$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) {
$date = substr($event['created_at'], 0, 10);
$result[$date] = ($result[$date] ?? 0) + 1;
}
$page++;
} while (count($events) === 100);
$this->logger->info('GitLabProvider: fetched contributions', [
'user' => $this->username,
'pages' => $page - 1,
'days' => count($result),
'total' => array_sum($result),
]);
return $result;
}
}
-64
View File
@@ -1,64 +0,0 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Fetches contribution data from the Gitea heatmap endpoint.
*
* Endpoint: GET /api/v1/users/{username}/heatmap
* Returns: [{"timestamp": 1234567890, "contributions": 3}, ...]
*
* Required token scopes: read:user
*/
class GiteaProvider implements ProviderInterface
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $username,
private readonly string $token,
private readonly string $baseUrl,
private readonly LoggerInterface $logger,
) {}
public function isConfigured(): bool
{
return $this->username !== '' && $this->token !== '' && $this->baseUrl !== '';
}
/**
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function fetch(): 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();
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp();
$result = [];
foreach ($data as $entry) {
if ($entry['timestamp'] < $cutoff) {
continue;
}
$date = date('Y-m-d', $entry['timestamp']);
$result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions'];
}
$this->logger->info('GiteaProvider: fetched contributions', [
'user' => $this->username,
'days' => count($result),
'total' => array_sum($result),
]);
return $result;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
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.
*
* Required token scopes: read:user
*/
final class GitHubProvider implements ProviderInterface
{
use ProbeTrait;
private const GRAPHQL_URL = 'https://api.github.com/graphql';
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $username,
private readonly string $token,
private readonly LoggerInterface $logger,
) {}
public function getName(): string
{
return 'github';
}
public function isConfigured(): bool
{
return $this->username !== '' && $this->token !== '';
}
public function ping(): void
{
$this->client->request('GET', 'https://api.github.com/user', [
'headers' => ['Authorization' => "Bearer {$this->token}"],
])->getContent();
}
/** Fires the GraphQL contributionsCollection query bounded by $since/$until. */
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): ResponseInterface
{
$this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]);
$from = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
$to = ($until ?? new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
$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),
);
// 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],
]);
}
/**
* 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'])) {
throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors']));
}
$result = [];
$weeks = $data['data']['user']['contributionsCollection']['contributionCalendar']['weeks'] ?? [];
foreach ($weeks as $week) {
foreach ($week['contributionDays'] as $day) {
if ($day['contributionCount'] > 0) {
$result[$day['date']] = $day['contributionCount'];
}
}
}
$this->logger->info('GitHubProvider: fetched contributions', [
'user' => $this->username,
'days' => count($result),
'total' => array_sum($result),
]);
return $result;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Fetches the last 365 days of push/merge events from the GitLab REST API.
*
* Required token scopes: read_user, read_api
* Works with both gitlab.com and self-hosted instances.
*/
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,
string $baseUrl = '',
) {
$this->baseUrl = rtrim($baseUrl !== '' ? $baseUrl : 'https://gitlab.com', '/');
}
public function getName(): string
{
return 'gitlab';
}
public function isConfigured(): bool
{
return $this->username !== '' && $this->token !== '';
}
public function ping(): void
{
$this->client->request('GET', "$this->baseUrl/api/v4/user", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
])->getContent();
}
/**
* 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 startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array
{
$this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
$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 $this->baseUrl");
}
$userId = $users[0]['id'];
$after = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d');
$before = $until?->format('Y-m-d');
$query = ['after' => $after, 'per_page' => 100, 'page' => 1];
if ($before !== null) {
$query['before'] = $before;
}
$response = $this->client->request('GET', "$this->baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => $query,
]);
return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'before' => $before, 'page' => 1, 'response' => $response];
}
/**
* Follows pagination (100 events/page) until a short page is returned, counting events per day.
*
* @param array{baseUrl: string, userId: int, after: string, before: ?string, page: int, response: ResponseInterface} $handle
* @return array<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 = [];
do {
$events = $response->toArray();
foreach ($events as $event) {
$date = substr($event['created_at'], 0, 10);
$result[$date] = ($result[$date] ?? 0) + 1;
}
$page++;
if (count($events) === 100) {
$query = ['after' => $after, 'per_page' => 100, 'page' => $page];
if ($before !== null) {
$query['before'] = $before;
}
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => $query,
]);
}
} while (count($events) === 100);
$this->logger->info('GitLabProvider: fetched contributions', [
'user' => $this->username,
'pages' => $page - 1,
'days' => count($result),
'total' => array_sum($result),
]);
return $result;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Fetches contribution data from the Gitea heatmap endpoint.
*
* Endpoint: GET /api/v1/users/{username}/heatmap
* Returns: [{"timestamp": 1234567890, "contributions": 3}, ...]
*
* Required token scopes: read:user
*/
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,
string $baseUrl,
private readonly LoggerInterface $logger,
) {
$this->baseUrl = rtrim($baseUrl, '/');
}
public function getName(): string
{
return 'gitea';
}
public function isConfigured(): bool
{
return $this->username !== '' && $this->token !== '' && $this->baseUrl !== '';
}
public function ping(): void
{
$this->client->request('GET', "$this->baseUrl/api/v1/user", [
'headers' => ['Authorization' => "token {$this->token}"],
])->getContent();
}
/**
* Fires a single heatmap request; $since/$until are carried through unused for client-side filtering in resolveFetch().
*
* @return array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable}
*/
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array
{
$this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
$response = $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [
'headers' => ['Authorization' => "token {$this->token}"],
]);
return ['response' => $response, 'since' => $since, 'until' => $until];
}
/**
* Filters the heatmap entries to the [$since, $until] window and sums contributions per day.
*
* @param array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable} $handle
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function resolveFetch(mixed $handle): array
{
['response' => $response, 'since' => $since, 'until' => $until] = $handle;
/** @var ResponseInterface $response */
$data = $response->toArray();
$cutoff = ($since ?? new \DateTimeImmutable('-365 days'))->getTimestamp();
$ceiling = $until?->getTimestamp();
$result = [];
foreach ($data as $entry) {
if ($entry['timestamp'] < $cutoff) {
continue;
}
if ($ceiling !== null && $entry['timestamp'] > $ceiling) {
continue;
}
$date = date('Y-m-d', $entry['timestamp']);
$result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions'];
}
$this->logger->info('GiteaProvider: fetched contributions', [
'user' => $this->username,
'days' => count($result),
'total' => array_sum($result),
]);
return $result;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
trait ProbeTrait
{
/**
* Reports NotConfigured if credentials are missing, otherwise pings the provider
* and reports Ok or Error (with a classified error code and message).
*/
public function probe(): ProviderStatus
{
if (!$this->isConfigured()) {
return new ProviderStatus($this->getName(), ProviderStatusType::NotConfigured);
}
try {
$this->ping();
return new ProviderStatus($this->getName(), ProviderStatusType::Ok);
} catch (\Throwable $e) {
return $this->statusFromException($e);
}
}
private function statusFromException(\Throwable $e): ProviderStatus
{
[$error, $message] = $this->classifyException($e);
return new ProviderStatus($this->getName(), ProviderStatusType::Error, $error, $message);
}
/** @return array{ProviderErrorCode, string} */
private function classifyException(\Throwable $e): array
{
if ($e instanceof TransportExceptionInterface) {
return [ProviderErrorCode::UrlUnreachable, 'Could not reach the server: ' . $e->getMessage()];
}
if ($e instanceof HttpExceptionInterface) {
$code = $e->getResponse()->getStatusCode();
return match (true) {
$code === 401 => [ProviderErrorCode::AuthFailed, 'Invalid or expired token — verify your credentials'],
$code === 403 => [ProviderErrorCode::AuthFailed, 'Access denied — token lacks the required scopes'],
$code === 404 => [ProviderErrorCode::UrlUnreachable, 'Endpoint not found — check the configured URL'],
default => [ProviderErrorCode::Unknown, "HTTP {$code}: " . $e->getMessage()],
};
}
$msg = $e->getMessage();
$lower = strtolower($msg);
$error = match (true) {
str_contains($msg, 'not found') || str_contains($msg, 'Could not resolve') => ProviderErrorCode::UserNotFound,
str_contains($msg, 'GraphQL error')
&& (str_contains($lower, 'unauthorized') || str_contains($lower, 'bad credentials')) => ProviderErrorCode::AuthFailed,
default => ProviderErrorCode::Unknown,
};
return [$error, $msg];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
enum ProviderErrorCode: string
{
case AuthFailed = 'auth_failed';
case UrlUnreachable = 'url_unreachable';
case UserNotFound = 'user_not_found';
case Unknown = 'unknown';
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ProviderHealthChecker
{
/** @param iterable<ProviderInterface> $providers */
public function __construct(
#[AutowireIterator('app.provider')]
private readonly iterable $providers,
) {}
/**
* 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
{
$statuses = [];
$hasError = false;
/** @var ProviderInterface $provider */
foreach ($this->providers as $provider) {
$status = $provider->probe();
$statuses[$status->name] = $status->toArray();
if ($status->status === ProviderStatusType::Error) {
$hasError = true;
}
}
return [
'status' => $hasError ? 'degraded' : 'ok',
'providers' => $statuses,
];
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
interface ProviderInterface
{
/**
* 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;
/** Short lowercase identifier for this provider, e.g. "github". */
public function getName(): string;
/** Check reachability/credentials without fetching contribution data; used by the /health endpoint. */
public function probe(): ProviderStatus;
/** Make a lightweight authenticated request to verify the provider is reachable; throws on failure. */
public function ping(): void;
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
/** Result of probing a single provider, as reported by the /health endpoint. */
final class ProviderStatus
{
/**
* @param string $name provider identifier, e.g. "github"
* @param ProviderStatusType $status overall outcome of the probe
* @param ?ProviderErrorCode $error classified error code, set only when $status is Error
* @param ?string $message human-readable error detail, set only when $status is Error
*/
public function __construct(
public readonly string $name,
public readonly ProviderStatusType $status,
public readonly ?ProviderErrorCode $error = null,
public readonly ?string $message = null,
) {}
/**
* Converts to the array shape used in the /health JSON response, omitting error/message when unset.
*
* @return array<string, string>
*/
public function toArray(): array
{
$data = ['status' => $this->status->value];
if ($this->error !== null) {
$data['error'] = $this->error->value;
}
if ($this->message !== null) {
$data['message'] = $this->message;
}
return $data;
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
enum ProviderStatusType: string
{
case Ok = 'ok';
case Error = 'error';
case NotConfigured = 'not_configured';
}
-13
View File
@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
interface ProviderInterface
{
/** @return array<string, int> date (Y-m-d) => contribution count */
public function fetch(): array;
public function isConfigured(): bool;
}
@@ -1,6 +1,8 @@
<?php <?php
namespace App\Service; declare(strict_types=1);
namespace GitContributionGraph\Service\Renderer;
/** /**
* Renders a GitHub-style contribution heatmap as an inline SVG. * Renders a GitHub-style contribution heatmap as an inline SVG.
@@ -11,7 +13,7 @@ namespace App\Service;
* - Month labels above, weekday labels (Mon/Wed/Fri) on the left * - Month labels above, weekday labels (Mon/Wed/Fri) on the left
* - 5 intensity levels (04) matched to GitHub's colour palette * - 5 intensity levels (04) matched to GitHub's colour palette
*/ */
class SvgRenderer final class SvgRenderer
{ {
// GitHub's exact colour tokens // GitHub's exact colour tokens
private const THEMES = [ private const THEMES = [
@@ -34,6 +36,12 @@ 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'];
@@ -89,6 +97,9 @@ 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
{ {
@@ -123,6 +134,10 @@ 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)];
@@ -130,6 +145,7 @@ 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 = '';
@@ -140,7 +156,7 @@ 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);
@@ -181,6 +197,10 @@ 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 = '';
@@ -195,7 +215,7 @@ 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)
@@ -216,6 +236,7 @@ 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);
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service;
use GitContributionGraph\Service\ContributionAggregator;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
#[CoversClass(ContributionAggregator::class)]
final class ContributionAggregatorTest extends TestCase
{
private LoggerInterface $logger;
private ContributionStore $store;
protected function setUp(): void
{
$this->logger = $this->createStub(LoggerInterface::class);
$this->store = new ContributionStore(':memory:');
}
/** @param ?array<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]
public function it_returns_empty_array_when_no_providers_are_given(): void
{
$aggregator = new ContributionAggregator([], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame([], $result);
}
#[Test]
public function it_skips_unconfigured_providers(): void
{
$provider = $this->makeProvider('github', configured: false);
$aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame([], $result);
}
#[Test]
public function it_returns_contributions_from_a_configured_provider(): void
{
$date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$provider = $this->makeProvider('github', fresh: [$date => 3]);
$aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame([$date => 3], $result);
}
#[Test]
public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void
{
$dateA = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$dateB = (new \DateTimeImmutable('-2 days'))->format('Y-m-d');
$providerA = $this->makeProvider('github', fresh: [$dateA => 3, $dateB => 1]);
$providerB = $this->makeProvider('gitlab', fresh: [$dateA => 2]);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame([$dateB => 1, $dateA => 5], $result);
}
#[Test]
public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void
{
$date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$failing = $this->createStub(ProviderInterface::class);
$failing->method('getName')->willReturn('gitlab');
$failing->method('isConfigured')->willReturn(true);
$failing->method('startFetch')->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_continues_fetching_remaining_providers_when_one_throws_on_resolve(): void
{
$date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$failing = $this->createStub(ProviderInterface::class);
$failing->method('getName')->willReturn('gitlab');
$failing->method('isConfigured')->willReturn(true);
$failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->makeProvider('github', fresh: [$date => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame([$date => 7], $result);
}
#[Test]
public function it_logs_a_warning_when_a_provider_start_fetch_throws(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class);
$provider->method('getName')->willReturn('github');
$provider->method('isConfigured')->willReturn(true);
$provider->method('startFetch')->willThrowException(new \RuntimeException('fail'));
(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'));
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Provider;
use GitContributionGraph\Service\Provider\GitHubProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
#[CoversClass(GitHubProvider::class)]
final class GitHubProviderTest extends TestCase
{
private function makeProvider(
string $username = 'user',
string $token = 'token',
?HttpClientInterface $client = null,
): GitHubProvider {
return new GitHubProvider(
$client ?? $this->createStub(HttpClientInterface::class),
$username,
$token,
$this->createStub(LoggerInterface::class),
);
}
/** @param array<int, array{contributionDays: array<int, array{date: string, contributionCount: int}>}> $weeks */
private function stubGraphqlResponse(array $weeks): ResponseInterface
{
$response = $this->createStub(ResponseInterface::class);
$response->method('toArray')->willReturn([
'data' => [
'user' => [
'contributionsCollection' => [
'contributionCalendar' => ['weeks' => $weeks],
],
],
],
]);
return $response;
}
#[Test]
public function it_returns_github_as_name(): void
{
$this->assertSame('github', $this->makeProvider()->getName());
}
#[Test]
public function it_is_configured_when_credentials_are_set(): void
{
$this->assertTrue($this->makeProvider()->isConfigured());
}
#[Test]
public function it_is_not_configured_when_username_is_empty(): void
{
$this->assertFalse($this->makeProvider(username: '')->isConfigured());
}
#[Test]
public function it_is_not_configured_when_token_is_empty(): void
{
$this->assertFalse($this->makeProvider(token: '')->isConfigured());
}
#[Test]
public function it_parses_contribution_days_from_the_graphql_response(): void
{
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubGraphqlResponse([
['contributionDays' => [
['date' => '2024-06-10', 'contributionCount' => 4],
['date' => '2024-06-11', 'contributionCount' => 2],
]],
]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(4, $result['2024-06-10']);
$this->assertSame(2, $result['2024-06-11']);
}
#[Test]
public function it_skips_days_with_zero_contributions(): void
{
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubGraphqlResponse([
['contributionDays' => [
['date' => '2024-06-11', 'contributionCount' => 0],
]],
]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertArrayNotHasKey('2024-06-11', $result);
}
#[Test]
public function it_throws_service_unavailable_exception_on_graphql_errors(): void
{
$response = $this->createStub(ResponseInterface::class);
$response->method('toArray')->willReturn([
'errors' => [['message' => 'Bad credentials']],
]);
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($response);
$this->expectException(ServiceUnavailableHttpException::class);
$provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
}
#[Test]
public function it_returns_empty_when_response_has_no_weeks(): void
{
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubGraphqlResponse([]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result);
}
#[Test]
public function it_narrows_the_graphql_query_window_when_since_and_until_are_given(): void
{
$since = new \DateTimeImmutable('2024-01-01');
$until = new \DateTimeImmutable('2024-01-31');
$capturedQuery = null;
$client = $this->createMock(HttpClientInterface::class);
$client->method('request')->willReturnCallback(
function (string $method, string $url, array $options) use (&$capturedQuery): ResponseInterface {
$capturedQuery = $options['json']['query'];
return $this->stubGraphqlResponse([]);
}
);
$provider = $this->makeProvider(client: $client);
$provider->startFetch($since, $until);
$this->assertStringContainsString('2024-01-01T00:00:00Z', $capturedQuery);
$this->assertStringContainsString('2024-01-31T23:59:59Z', $capturedQuery);
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Provider;
use GitContributionGraph\Service\Provider\GitLabProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
#[CoversClass(GitLabProvider::class)]
final class GitLabProviderTest extends TestCase
{
private function makeProvider(
string $username = 'user',
string $token = 'token',
string $baseUrl = '',
?HttpClientInterface $client = null,
): GitLabProvider {
return new GitLabProvider(
$client ?? $this->createStub(HttpClientInterface::class),
$username,
$token,
$this->createStub(LoggerInterface::class),
$baseUrl,
);
}
/** @param array<int, array<string, int|string>> $data raw JSON body, e.g. events or the user lookup */
private function stubResponse(array $data): ResponseInterface
{
$response = $this->createStub(ResponseInterface::class);
$response->method('toArray')->willReturn($data);
return $response;
}
#[Test]
public function it_returns_gitlab_as_name(): void
{
$this->assertSame('gitlab', $this->makeProvider()->getName());
}
#[Test]
public function it_is_configured_when_credentials_are_set(): void
{
$this->assertTrue($this->makeProvider()->isConfigured());
}
#[Test]
public function it_is_not_configured_when_username_is_empty(): void
{
$this->assertFalse($this->makeProvider(username: '')->isConfigured());
}
#[Test]
public function it_is_not_configured_when_token_is_empty(): void
{
$this->assertFalse($this->makeProvider(token: '')->isConfigured());
}
#[Test]
public function it_throws_not_found_exception_when_user_does_not_exist(): void
{
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([]));
$this->expectException(NotFoundHttpException::class);
$provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
}
#[Test]
public function it_fetches_events_and_counts_them_by_date(): void
{
$client = $this->createMock(HttpClientInterface::class);
$client->method('request')->willReturnCallback(
function (string $method, string $url): ResponseInterface {
if (str_contains($url, '/events')) {
return $this->stubResponse([
['created_at' => '2024-06-10T12:00:00.000Z'],
['created_at' => '2024-06-10T14:00:00.000Z'],
['created_at' => '2024-06-11T08:00:00.000Z'],
]);
}
return $this->stubResponse([['id' => 42]]);
}
);
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(2, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']);
}
#[Test]
public function it_fetches_multiple_pages_until_page_has_fewer_than_100_events(): void
{
$callCount = 0;
$client = $this->createMock(HttpClientInterface::class);
$client->method('request')->willReturnCallback(
function (string $method, string $url) use (&$callCount): ResponseInterface {
if (!str_contains($url, '/events')) {
return $this->stubResponse([['id' => 42]]);
}
$callCount++;
$data = $callCount === 1
? array_fill(0, 100, ['created_at' => '2024-06-10T12:00:00.000Z'])
: [['created_at' => '2024-06-11T08:00:00.000Z']];
return $this->stubResponse($data);
}
);
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(2, $callCount);
$this->assertSame(100, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']);
}
#[Test]
public function it_sends_after_and_before_query_params_when_since_and_until_are_given(): void
{
$capturedQuery = null;
$client = $this->createMock(HttpClientInterface::class);
$client->method('request')->willReturnCallback(
function (string $method, string $url, array $options = []) use (&$capturedQuery): ResponseInterface {
if (str_contains($url, '/events')) {
$capturedQuery = $options['query'];
}
return $this->stubResponse(str_contains($url, '/events') ? [] : [['id' => 42]]);
}
);
$provider = $this->makeProvider(client: $client);
$provider->startFetch(new \DateTimeImmutable('2024-01-01'), new \DateTimeImmutable('2024-01-31'));
$this->assertSame('2024-01-01', $capturedQuery['after']);
$this->assertSame('2024-01-31', $capturedQuery['before']);
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Provider;
use GitContributionGraph\Service\Provider\GiteaProvider;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
#[CoversClass(GiteaProvider::class)]
final class GiteaProviderTest extends TestCase
{
private function makeProvider(
string $username = 'user',
string $token = 'token',
string $baseUrl = 'https://gitea.example.com',
?HttpClientInterface $client = null,
): GiteaProvider {
return new GiteaProvider(
$client ?? $this->createStub(HttpClientInterface::class),
$username,
$token,
$baseUrl,
$this->createStub(LoggerInterface::class),
);
}
/** @param array<int, array{timestamp: int, contributions: int}> $data */
private function stubResponse(array $data): ResponseInterface
{
$response = $this->createStub(ResponseInterface::class);
$response->method('toArray')->willReturn($data);
return $response;
}
#[Test]
public function it_returns_gitea_as_name(): void
{
$this->assertSame('gitea', $this->makeProvider()->getName());
}
#[Test]
public function it_is_configured_when_all_credentials_are_set(): void
{
$this->assertTrue($this->makeProvider()->isConfigured());
}
#[Test]
public function it_is_not_configured_when_username_is_empty(): void
{
$this->assertFalse($this->makeProvider(username: '')->isConfigured());
}
#[Test]
public function it_is_not_configured_when_token_is_empty(): void
{
$this->assertFalse($this->makeProvider(token: '')->isConfigured());
}
#[Test]
public function it_is_not_configured_when_base_url_is_empty(): void
{
$this->assertFalse($this->makeProvider(baseUrl: '')->isConfigured());
}
#[Test]
public function it_parses_heatmap_entries_into_contributions(): void
{
$now = time();
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([
['timestamp' => $now, 'contributions' => 5],
]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(5, $result[date('Y-m-d', $now)]);
}
#[Test]
public function it_filters_out_entries_older_than_365_days(): void
{
$old = (new \DateTimeImmutable('-366 days'))->getTimestamp();
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([
['timestamp' => $old, 'contributions' => 3],
]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result);
}
#[Test]
public function it_returns_empty_when_response_has_no_entries(): void
{
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result);
}
#[Test]
public function it_filters_out_entries_before_since(): void
{
$since = new \DateTimeImmutable('2024-01-10');
$before = $since->modify('-1 day')->getTimestamp();
$after = $since->modify('+1 day')->getTimestamp();
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([
['timestamp' => $before, 'contributions' => 3],
['timestamp' => $after, 'contributions' => 2],
]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch($since));
$this->assertArrayNotHasKey(date('Y-m-d', $before), $result);
$this->assertSame(2, $result[date('Y-m-d', $after)]);
}
#[Test]
public function it_filters_out_entries_after_until(): void
{
$until = new \DateTimeImmutable('-10 days');
$before = $until->modify('-1 day')->getTimestamp();
$after = $until->modify('+1 day')->getTimestamp();
$client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([
['timestamp' => $before, 'contributions' => 3],
['timestamp' => $after, 'contributions' => 2],
]));
$provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch(null, $until));
$this->assertSame(3, $result[date('Y-m-d', $before)]);
$this->assertArrayNotHasKey(date('Y-m-d', $after), $result);
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Provider;
use GitContributionGraph\Service\Provider\ProbeTrait;
use GitContributionGraph\Service\Provider\ProviderErrorCode;
use GitContributionGraph\Service\Provider\ProviderInterface;
use GitContributionGraph\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
#[CoversClass(ProbeTrait::class)]
final class ProbeTraitTest extends TestCase
{
private function makeProvider(bool $configured, \Closure $ping): ProviderInterface
{
return new class($configured, $ping) implements ProviderInterface {
use ProbeTrait;
public function __construct(
private bool $configured,
private \Closure $ping,
) {}
public function isConfigured(): bool { return $this->configured; }
public function getName(): string { return 'test'; }
public function ping(): void { ($this->ping)(); }
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed { return null; }
public function resolveFetch(mixed $handle): array { return []; }
};
}
#[Test]
public function it_returns_not_configured_when_provider_is_not_configured(): void
{
$provider = $this->makeProvider(false, fn() => null);
$status = $provider->probe();
$this->assertSame(ProviderStatusType::NotConfigured, $status->status);
$this->assertNull($status->error);
}
#[Test]
public function it_returns_ok_when_ping_succeeds(): void
{
$provider = $this->makeProvider(true, fn() => null);
$status = $provider->probe();
$this->assertSame(ProviderStatusType::Ok, $status->status);
$this->assertNull($status->error);
}
#[Test]
public function it_returns_url_unreachable_on_transport_failure(): void
{
$exception = $this->createStub(TransportExceptionInterface::class);
$provider = $this->makeProvider(true, fn() => throw $exception);
$status = $provider->probe();
$this->assertSame(ProviderStatusType::Error, $status->status);
$this->assertSame(ProviderErrorCode::UrlUnreachable, $status->error);
}
#[Test]
public function it_returns_auth_failed_on_401(): void
{
$response = $this->createStub(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(401);
$exception = $this->createStub(ClientExceptionInterface::class);
$exception->method('getResponse')->willReturn($response);
$status = $this->makeProvider(true, fn() => throw $exception)->probe();
$this->assertSame(ProviderStatusType::Error, $status->status);
$this->assertSame(ProviderErrorCode::AuthFailed, $status->error);
}
#[Test]
public function it_returns_auth_failed_on_403(): void
{
$response = $this->createStub(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(403);
$exception = $this->createStub(ClientExceptionInterface::class);
$exception->method('getResponse')->willReturn($response);
$status = $this->makeProvider(true, fn() => throw $exception)->probe();
$this->assertSame(ProviderStatusType::Error, $status->status);
$this->assertSame(ProviderErrorCode::AuthFailed, $status->error);
}
#[Test]
public function it_returns_url_unreachable_on_404(): void
{
$response = $this->createStub(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(404);
$exception = $this->createStub(ClientExceptionInterface::class);
$exception->method('getResponse')->willReturn($response);
$status = $this->makeProvider(true, fn() => throw $exception)->probe();
$this->assertSame(ProviderStatusType::Error, $status->status);
$this->assertSame(ProviderErrorCode::UrlUnreachable, $status->error);
}
#[Test]
public function it_returns_unknown_error_on_unexpected_exception(): void
{
$provider = $this->makeProvider(true, fn() => throw new \RuntimeException('Something went wrong'));
$status = $provider->probe();
$this->assertSame(ProviderStatusType::Error, $status->status);
$this->assertSame(ProviderErrorCode::Unknown, $status->error);
$this->assertSame('Something went wrong', $status->message);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Provider;
use GitContributionGraph\Service\Provider\ProviderErrorCode;
use GitContributionGraph\Service\Provider\ProviderHealthChecker;
use GitContributionGraph\Service\Provider\ProviderInterface;
use GitContributionGraph\Service\Provider\ProviderStatus;
use GitContributionGraph\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(ProviderHealthChecker::class)]
final class ProviderHealthCheckerTest extends TestCase
{
#[Test]
public function it_returns_ok_when_all_providers_are_healthy(): void
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('probe')->willReturn(new ProviderStatus('test', ProviderStatusType::Ok));
$result = (new ProviderHealthChecker([$provider]))->check();
$this->assertSame('ok', $result['status']);
}
#[Test]
public function it_returns_degraded_when_a_provider_has_an_error(): void
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('probe')->willReturn(
new ProviderStatus('test', ProviderStatusType::Error, ProviderErrorCode::AuthFailed, 'Invalid token'),
);
$result = (new ProviderHealthChecker([$provider]))->check();
$this->assertSame('degraded', $result['status']);
}
#[Test]
public function it_does_not_degrade_when_a_provider_is_not_configured(): void
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('probe')->willReturn(new ProviderStatus('test', ProviderStatusType::NotConfigured));
$result = (new ProviderHealthChecker([$provider]))->check();
$this->assertSame('ok', $result['status']);
}
#[Test]
public function it_indexes_provider_statuses_by_name(): void
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('probe')->willReturn(new ProviderStatus('github', ProviderStatusType::Ok));
$result = (new ProviderHealthChecker([$provider]))->check();
$this->assertArrayHasKey('github', $result['providers']);
}
#[Test]
public function it_returns_ok_with_no_providers(): void
{
$result = (new ProviderHealthChecker([]))->check();
$this->assertSame('ok', $result['status']);
$this->assertSame([], $result['providers']);
}
#[Test]
public function it_includes_all_provider_statuses_in_output(): void
{
$github = $this->createStub(ProviderInterface::class);
$github->method('probe')->willReturn(new ProviderStatus('github', ProviderStatusType::Ok));
$gitlab = $this->createStub(ProviderInterface::class);
$gitlab->method('probe')->willReturn(new ProviderStatus('gitlab', ProviderStatusType::NotConfigured));
$result = (new ProviderHealthChecker([$github, $gitlab]))->check();
$this->assertArrayHasKey('github', $result['providers']);
$this->assertArrayHasKey('gitlab', $result['providers']);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Provider;
use GitContributionGraph\Service\Provider\ProviderErrorCode;
use GitContributionGraph\Service\Provider\ProviderStatus;
use GitContributionGraph\Service\Provider\ProviderStatusType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(ProviderStatus::class)]
final class ProviderStatusTest extends TestCase
{
#[Test]
public function it_serializes_ok_status(): void
{
$status = new ProviderStatus('github', ProviderStatusType::Ok);
$this->assertSame(['status' => 'ok'], $status->toArray());
}
#[Test]
public function it_serializes_not_configured_status(): void
{
$status = new ProviderStatus('github', ProviderStatusType::NotConfigured);
$this->assertSame(['status' => 'not_configured'], $status->toArray());
}
#[Test]
public function it_serializes_error_status_with_code_and_message(): void
{
$status = new ProviderStatus('github', ProviderStatusType::Error, ProviderErrorCode::AuthFailed, 'Token expired');
$this->assertSame([
'status' => 'error',
'error' => 'auth_failed',
'message' => 'Token expired',
], $status->toArray());
}
#[Test]
public function it_omits_null_error_fields_from_array(): void
{
$status = new ProviderStatus('github', ProviderStatusType::Ok);
$array = $status->toArray();
$this->assertArrayNotHasKey('error', $array);
$this->assertArrayNotHasKey('message', $array);
}
#[Test]
public function it_exposes_typed_status_property(): void
{
$status = new ProviderStatus('github', ProviderStatusType::Error, ProviderErrorCode::Unknown, 'msg');
$this->assertSame(ProviderStatusType::Error, $status->status);
$this->assertSame(ProviderErrorCode::Unknown, $status->error);
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service\Renderer;
use GitContributionGraph\Service\Renderer\SvgRenderer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(SvgRenderer::class)]
final class SvgRendererTest extends TestCase
{
#[Test]
public function it_returns_an_svg_opening_tag(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringStartsWith('<svg', $svg);
}
#[Test]
public function it_returns_a_closed_svg_element(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringEndsWith('</svg>', $svg);
}
#[Test]
public function it_includes_role_img_attribute_for_accessibility(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('role="img"', $svg);
}
#[Test]
public function it_includes_aria_label_attribute_for_accessibility(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('aria-label="Contribution graph"', $svg);
}
#[Test]
#[DataProvider('theme_background_provider')]
public function it_applies_background_color_for_theme(string $theme, string $expectedColor): void
{
$svg = (new SvgRenderer())->render([], $theme);
$this->assertStringContainsString($expectedColor, $svg);
}
/** @return iterable<string, array{string, string}> */
public static function theme_background_provider(): iterable
{
yield 'dark theme' => ['dark', '#0d1117'];
yield 'light theme' => ['light', '#ffffff'];
yield 'unknown theme' => ['unknown', '#0d1117'];
}
#[Test]
public function it_shows_zero_contributions_when_no_data_is_provided(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('0 contributions in the last year', $svg);
}
#[Test]
public function it_displays_formatted_total_contribution_count(): void
{
$today = (new \DateTimeImmutable('today'))->format('Y-m-d');
$svg = (new SvgRenderer())->render([$today => 1234]);
$this->assertStringContainsString('1,234 contributions in the last year', $svg);
}
#[Test]
public function it_renders_all_53_week_columns(): void
{
$svg = (new SvgRenderer())->render([]);
// MARGIN_X(28) + col_52 * STEP(13) = 704
$this->assertStringContainsString('x="704"', $svg);
}
#[Test]
#[DataProvider('day_of_week_label_provider')]
public function it_renders_day_of_week_label(string $label): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('>' . $label . '<', $svg);
}
/** @return iterable<string, array{string}> */
public static function day_of_week_label_provider(): iterable
{
yield 'Monday' => ['Mon'];
yield 'Wednesday' => ['Wed'];
yield 'Friday' => ['Fri'];
}
#[Test]
public function it_renders_a_less_label_in_the_legend(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('>Less<', $svg);
}
#[Test]
public function it_renders_a_more_label_in_the_legend(): void
{
$svg = (new SvgRenderer())->render([]);
$this->assertStringContainsString('>More<', $svg);
}
#[Test]
public function it_sums_contributions_from_multiple_dates(): void
{
$today = (new \DateTimeImmutable('today'))->format('Y-m-d');
$yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
$svg = (new SvgRenderer())->render([$today => 3, $yesterday => 7]);
$this->assertStringContainsString('10 contributions in the last year', $svg);
}
}
-120
View File
@@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\SvgRenderer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(SvgRenderer::class)]
final class SvgRendererTest extends TestCase
{
private SvgRenderer $renderer;
protected function setUp(): void
{
$this->renderer = new SvgRenderer();
}
#[Test]
public function it_returns_a_valid_svg_element(): void
{
$svg = $this->renderer->render([]);
$this->assertStringStartsWith('<svg', $svg);
$this->assertStringEndsWith('</svg>', $svg);
}
#[Test]
public function it_includes_accessibility_attributes(): void
{
$svg = $this->renderer->render([]);
$this->assertStringContainsString('role="img"', $svg);
$this->assertStringContainsString('aria-label="Contribution graph"', $svg);
}
#[Test]
public function it_applies_dark_theme_background_color(): void
{
$svg = $this->renderer->render([], 'dark');
$this->assertStringContainsString('#0d1117', $svg);
}
#[Test]
public function it_applies_light_theme_background_color(): void
{
$svg = $this->renderer->render([], 'light');
$this->assertStringContainsString('#ffffff', $svg);
}
#[Test]
public function it_falls_back_to_dark_theme_for_unknown_theme_names(): void
{
$svg = $this->renderer->render([], 'unknown');
$this->assertStringContainsString('#0d1117', $svg);
}
#[Test]
public function it_shows_zero_contributions_when_no_data_is_provided(): void
{
$svg = $this->renderer->render([]);
$this->assertStringContainsString('0 contributions in the last year', $svg);
}
#[Test]
public function it_displays_formatted_total_contribution_count(): void
{
$today = (new \DateTimeImmutable('today'))->format('Y-m-d');
$svg = $this->renderer->render([$today => 1234]);
$this->assertStringContainsString('1,234 contributions in the last year', $svg);
}
#[Test]
public function it_renders_all_53_week_columns(): void
{
$svg = $this->renderer->render([]);
// MARGIN_X(28) + col_52 * STEP(13) = 704
$this->assertStringContainsString('x="704"', $svg);
}
#[Test]
public function it_renders_day_of_week_labels_matching_github(): void
{
$svg = $this->renderer->render([]);
$this->assertStringContainsString('>Mon<', $svg);
$this->assertStringContainsString('>Wed<', $svg);
$this->assertStringContainsString('>Fri<', $svg);
}
#[Test]
public function it_renders_a_legend_with_less_and_more_labels(): void
{
$svg = $this->renderer->render([]);
$this->assertStringContainsString('>Less<', $svg);
$this->assertStringContainsString('>More<', $svg);
}
#[Test]
public function it_sums_contributions_from_multiple_dates(): void
{
$today = (new \DateTimeImmutable('today'))->format('Y-m-d');
$yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
$svg = $this->renderer->render([$today => 3, $yesterday => 7]);
$this->assertStringContainsString('10 contributions in the last year', $svg);
}
}