Compare commits
126
Commits
c1fde0aed3
..
0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfde2ed798 | ||
|
|
a24bace57a | ||
|
|
4d479f0166 | ||
|
|
b3fa891b89 | ||
|
|
98bbb7017f | ||
|
|
3b1e78ebf5 | ||
|
|
a8f5680e6e | ||
|
|
658892a639 | ||
|
|
06fc829ff1 | ||
|
|
30a2439914 | ||
|
|
222c8aaa85 | ||
|
|
47d9a62db7 | ||
|
|
0b1fed2f93 | ||
|
|
f3491ef4a6 | ||
|
|
d6a4f7c41f | ||
|
|
211edc4538 | ||
|
|
14b325b3bf | ||
|
|
d15e25abf3 | ||
|
|
53186e9a35 | ||
|
|
7688fd77c9 | ||
|
|
6e79337a60 | ||
|
|
68424aa28b | ||
|
|
fdc7fa700d | ||
|
|
fb8f25d5ff | ||
|
|
34e558d31c | ||
|
|
c4dffb1dc6 | ||
|
|
4825c45a7c | ||
|
|
ac515400e0 | ||
|
|
be6146d5ad | ||
|
|
20e943a2b5 | ||
|
|
cf7bd8d70c | ||
|
|
de831beaaa | ||
|
|
9c028aaf5e | ||
|
|
8e349c961b | ||
|
|
14ea788522 | ||
|
|
4ff45b7c46 | ||
|
|
41e88144a4 | ||
|
|
91bda21f89 | ||
|
|
0d7cf68771 | ||
|
|
e7f14dfd35 | ||
|
|
095f3e0253 | ||
|
|
e2a324b74a | ||
|
|
a76ed6efc8 | ||
|
|
bce564d68b | ||
|
|
b3dc7a2298 | ||
|
|
9087f91855 | ||
|
|
885dec357e | ||
|
|
78e7a72ee9 | ||
|
|
c929fdfda6 | ||
|
|
3d2c0487b7 | ||
|
|
cb89a1c6b9 | ||
|
|
5e27a5bc93 | ||
|
|
f52a3449c6 | ||
|
|
ca94b80beb | ||
|
|
cd326a34ba | ||
|
|
8fb7781227 | ||
|
|
1e2f022579 | ||
|
|
c89b4e9212 | ||
|
|
5c47888a49 | ||
|
|
f2bf24994c | ||
|
|
fcf841f7ad | ||
|
|
9b631f4777 | ||
|
|
596260bb85 | ||
|
|
9f3c54afd1 | ||
|
|
11bec43bbe | ||
|
|
09c0be8312 | ||
|
|
6d6680ca37 | ||
|
|
406da07397 | ||
|
|
d5d948f423 | ||
|
|
a6e904fc61 | ||
|
|
980463f715 | ||
|
|
095b9675f9 | ||
|
|
8dbfef8496 | ||
|
|
a527eada56 | ||
|
|
423ac5470d | ||
|
|
4e992c8f79 | ||
|
|
4983492088 | ||
|
|
169fa8c76a | ||
|
|
4cefba1a37 | ||
|
|
28a0916487 | ||
|
|
4cfa61f1c0 | ||
|
|
2f3268c0b7 | ||
|
|
89d0e2b0f6 | ||
|
|
fad176419c | ||
|
|
3743ba31d2 | ||
|
|
8a675cf02f | ||
|
|
20c5acc5ae | ||
|
|
21867256e8 | ||
|
|
67d4a50ee1 | ||
|
|
e72ee2541e | ||
|
|
ecdb8c1716 | ||
|
|
841f4329de | ||
|
|
50256c97ef | ||
|
|
2f66c65b30 | ||
|
|
38312f549c | ||
|
|
a8d5f205db | ||
|
|
85428826a0 | ||
|
|
61b7735afc | ||
|
|
c70d96c3aa | ||
|
|
d3b9463c57 | ||
|
|
71bfb38028 | ||
|
|
225c614057 | ||
|
|
c205fed14b | ||
|
|
3e3a6752af | ||
|
|
5b07eae672 | ||
|
|
235e63bfc0 | ||
|
|
92380e534a | ||
|
|
4654a287a8 | ||
|
|
2427c07f4c | ||
|
|
7c1b893e09 | ||
|
|
4993fdca00 | ||
|
|
c4803ea5cb | ||
|
|
19bfa4cf28 | ||
|
|
e70d035672 | ||
|
|
d57320cb77 | ||
|
|
5feae97dd3 | ||
|
|
cceda8abf2 | ||
|
|
8b8ec9ab2f | ||
|
|
b3e79de069 | ||
|
|
f3770f1c2a | ||
|
|
56035096c6 | ||
|
|
381b8f4489 | ||
|
|
fa035781fd | ||
|
|
d1dce19832 | ||
|
|
0c2a185927 | ||
|
|
ff44161a99 |
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Build descriptors (not application code)
|
||||||
|
Dockerfile*
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
|
||||||
|
# AI / IDE tooling
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Dev tooling
|
||||||
|
.gitignore
|
||||||
|
/.phpunit.cache
|
||||||
|
phpunit.xml.dist
|
||||||
|
tests/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
CHANGELOG.md
|
||||||
|
CLAUDE.md
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# Compose / deployment descriptors (not app code)
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.prod.yml
|
||||||
|
|
||||||
|
# Dependencies — re-installed from lockfile in the build stage;
|
||||||
|
# a local vendor/ in the build context would silently override the clean install
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Runtime dirs (generated at build or run time, not from source)
|
||||||
|
var/
|
||||||
|
|
||||||
|
# Env files — .env contains only placeholder defaults and is needed by composer dump-env;
|
||||||
|
# local overrides with real secrets stay excluded
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Builds and pushes a multi-arch Docker image to the Gitea container registry.
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Requires a repository secret REGISTRY_TOKEN — a Gitea PAT with write:package scope.
|
||||||
|
# Create it at: Settings → Applications → Generate Token (scope: write:package)
|
||||||
|
# Then add it: Repository → Settings → Secrets → Actions → REGISTRY_TOKEN
|
||||||
|
#
|
||||||
|
# After a successful run the image is available at:
|
||||||
|
# <your-gitea-host>/<owner>/<repo>:<version>
|
||||||
|
|
||||||
|
name: Docker Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag (semver, e.g. 1.2.3)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.arthurerlich.de
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate tag exists
|
||||||
|
run: |
|
||||||
|
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 provided release tag.
|
||||||
|
# 1.2.3 → image tags: 1.2.3 / 1.2 / 1
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ inputs.tag }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||||
|
|
||||||
|
# QEMU enables emulation of arm64 on the amd64 runner.
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
# BuildKit driver required for multi-platform builds and layer caching.
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: final
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# Registry-based layer cache — survives between runs without a separate cache store.
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache,mode=max
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Cuts a release: moves CHANGELOG.md's [Unreleased] section under a new
|
||||||
|
# version heading, commits it to main, then creates and pushes the version tag.
|
||||||
|
# Trigger manually via workflow_dispatch, entering the semver to release.
|
||||||
|
# The pushed tag can then be built with docker-publish.yml.
|
||||||
|
|
||||||
|
name: Create Release Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to release (semver, e.g. 1.2.3)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Validate version and Unreleased section
|
||||||
|
run: |
|
||||||
|
if ! [[ "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Error: '${{ inputs.version }}' is not a valid semver (e.g. 1.2.3)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git rev-parse "refs/tags/${{ inputs.version }}" >/dev/null 2>&1; then
|
||||||
|
echo "Error: tag '${{ inputs.version }}' already exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
awk '/^## \[Unreleased\]/{f=1;next}/^## \[/{f=0}f' CHANGELOG.md > /tmp/unreleased.md
|
||||||
|
if ! grep -q '[^[:space:]]' /tmp/unreleased.md; then
|
||||||
|
echo "Error: CHANGELOG.md's [Unreleased] section is empty — nothing to release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Move Unreleased entries under the new version heading
|
||||||
|
run: |
|
||||||
|
today=$(date +%Y-%m-%d)
|
||||||
|
awk -v ver="${{ inputs.version }}" -v date="$today" '
|
||||||
|
/^## \[Unreleased\]/ { print; print ""; print "## [" ver "] - " date; next }
|
||||||
|
{ print }
|
||||||
|
' CHANGELOG.md > /tmp/CHANGELOG.md
|
||||||
|
mv /tmp/CHANGELOG.md CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Commit and tag
|
||||||
|
run: |
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@noreply.git.arthurerlich.de"
|
||||||
|
git add CHANGELOG.md
|
||||||
|
git commit -m "chore(release): ${{ inputs.version }}"
|
||||||
|
git tag "${{ inputs.version }}"
|
||||||
|
git push origin HEAD:main
|
||||||
|
git push origin "${{ inputs.version }}"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
load: true
|
||||||
|
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: Run tests in dev image
|
||||||
|
run: |
|
||||||
|
cid=$(docker create graph-dev:ci sh -c "
|
||||||
|
composer install --no-interaction &&
|
||||||
|
vendor/bin/phpunit --testdox &&
|
||||||
|
composer phpstan
|
||||||
|
")
|
||||||
|
docker cp . "$cid":/app
|
||||||
|
rc=0
|
||||||
|
docker start -a "$cid" || rc=$?
|
||||||
|
docker rm "$cid" > /dev/null
|
||||||
|
exit $rc
|
||||||
|
|
||||||
|
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: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build prod image (final target)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: final
|
||||||
|
push: false
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache-prod
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository }}:buildcache-prod,mode=max
|
||||||
+1
-1
@@ -3,4 +3,4 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
/var/
|
/var/
|
||||||
/public/bundles/
|
/public/bundles/
|
||||||
composer.lock
|
/.phpunit.cache
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 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]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed a leftover `composer dump-env prod` call in the prod Docker build stage — the command doesn't exist in this project's Composer setup and broke every image build
|
||||||
|
|
||||||
|
## [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
|
||||||
@@ -16,39 +16,218 @@ curl "http://localhost:8080/graph.svg?theme=dark" -o graph.svg
|
|||||||
curl "http://localhost:8080/health"
|
curl "http://localhost:8080/health"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
### Testing conventions (apply to every test file)
|
||||||
|
|
||||||
|
- `declare(strict_types=1)` at the top of every test file
|
||||||
|
- Test classes are `final` and extend `TestCase`
|
||||||
|
- Methods: `#[Test]` attribute + `it_` prefix + `snake_case` — e.g. `it_returns_empty_when_no_contributions`
|
||||||
|
- Structure: Arrange → Act → Assert, separated by blank lines; one assertion per test when possible
|
||||||
|
- Use `$this->assert*()` not `self::assert*()`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Service\SvgRenderer;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SvgRendererTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function it_renders_an_svg_with_no_contributions(): void
|
||||||
|
{
|
||||||
|
$renderer = new SvgRenderer();
|
||||||
|
|
||||||
|
$svg = $renderer->render([], 'light');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<svg', $svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TDD: Red → Green → Refactor
|
||||||
|
|
||||||
|
Always drive new features and bug fixes with this cycle. Claude writes implementation first by default — override that with explicit prompts.
|
||||||
|
|
||||||
|
**Phase 1 — Red (write a failing test first)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Write a failing test for [feature description].
|
||||||
|
Do NOT write the implementation yet.
|
||||||
|
The test should fail because the class/method does not exist.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 2 — Green (minimal implementation)**
|
||||||
|
|
||||||
|
```
|
||||||
|
The tests are written and failing. Now implement the minimum code to make them pass. Nothing more.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 3 — Refactor**
|
||||||
|
|
||||||
|
```
|
||||||
|
Tests are green. Refactor the implementation for [readability / removing duplication / naming].
|
||||||
|
Run vendor/bin/phpunit after each change to confirm tests stay green.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common anti-patterns**
|
||||||
|
|
||||||
|
| 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." |
|
||||||
|
| "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." |
|
||||||
|
| Combining Red + Green in one request | No failing baseline | Always separate the two phases |
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
Prefer to use the tests in `docker compose exec graph`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and run
|
# Run full suite
|
||||||
|
vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Run with human-readable output
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php
|
||||||
|
|
||||||
|
# Run tests matching a filter
|
||||||
|
vendor/bin/phpunit --filter it_renders
|
||||||
|
|
||||||
|
# Static analysis (PHPStan level 8)
|
||||||
|
composer phpstan
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, run via WSL (If Docker Desctop is not):
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
Add to `.claude/settings.json` to run PHPUnit automatically after every file edit:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"command": "vendor/bin/phpunit 2>&1 | tail -20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
`docker-compose.override.yml` is picked up automatically and targets the `dev` stage (Xdebug + all deps, source mounted).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev container (override applied automatically)
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# Force cache clear (clears the 1h filesystem cache)
|
# Shell into the container to run commands
|
||||||
|
docker compose exec graph sh
|
||||||
|
|
||||||
|
# Run tests inside the container
|
||||||
|
docker compose exec graph vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Disable Xdebug for faster test runs
|
||||||
|
XDEBUG_MODE=off docker compose up -d
|
||||||
|
|
||||||
|
# Force cache clear
|
||||||
docker compose exec graph rm -rf var/cache/*
|
docker compose exec graph rm -rf var/cache/*
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
docker compose logs -f graph
|
docker compose logs -f graph
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no `composer.lock` in the repo. If you add or change dependencies, run `composer install` locally and commit the resulting lock file — without it, Docker builds resolve versions fresh each time and may produce inconsistent results.
|
**Xdebug:** listens on port `9003`. Configure your IDE to accept connections from Docker. On Linux `host.docker.internal` is resolved via `extra_hosts: host-gateway` in the override file.
|
||||||
|
|
||||||
The Dockerfile runs `composer install --no-scripts` (skipping Symfony post-install scripts) then `composer dump-autoload --optimize --no-dev` in the final stage. If `bin/console` fails with a missing class error inside the container, the most likely cause is the absent lock file causing an incomplete dependency resolution.
|
### Production
|
||||||
|
|
||||||
|
Use only the base compose file to skip the dev override:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The `final` stage runs as a non-root `app` user and contains no Composer binary. The build pipeline is:
|
||||||
|
|
||||||
|
```
|
||||||
|
base → deps (composer install --no-dev)
|
||||||
|
→ build (copy source + dump-autoload)
|
||||||
|
→ final (copy vendor + source, chown app, USER app)
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no `composer.lock` in the repo. If you add or change dependencies, run `composer install` locally and commit the resulting lock file — without it, Docker builds resolve versions fresh each time and may produce inconsistent results.
|
||||||
|
|
||||||
## 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
|
||||||
```
|
```
|
||||||
@@ -57,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, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's exact colour tokens. No external assets — the SVG is fully self-contained.
|
**SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 contributions → level 0, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's exact colour tokens. No external assets — the SVG is fully self-contained.
|
||||||
|
|
||||||
**Cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently.
|
**SVG cache:** filesystem adapter (`var/cache/`), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently.
|
||||||
|
|
||||||
|
**ContributionStore:** PDO SQLite at `var/data/contributions.db` (also a Docker volume), table `contributions (provider, date unixtime, count)`. Keyed by `(provider, date)`, upserted via `INSERT ... ON CONFLICT`. Bounds every re-fetch to a 3-day trailing window off the last stored date per provider — the root fix for the full-365-day-refetch `MaxExecutionTimeError` that used to hit on every cache miss. `CONTRIBUTIONS_RETENTION_DAYS` controls how far back rows are kept (empty = forever).
|
||||||
|
|
||||||
|
**`graph:contributions:refetch` command:** manual escape hatch (`src/Command/RefetchContributionsCommand.php`) that bypasses the incremental trailing-window fetch and re-pulls a full or explicit date range. Options: `--provider=github,gitlab` (comma-separated, default all configured), `--from=YYYY-MM-DD` (default 365 days ago), `--to=YYYY-MM-DD` (default today), `--all` (shorthand for `--from=2005-01-01`). Splits the requested range into ≤365-day chunks (GitHub's GraphQL window limit; also caps GitLab pagination per call) and merges each chunk into the store as it completes, so a large `--all` run can't reintroduce the original runaway-pagination timeout.
|
||||||
|
|
||||||
## Environment variables
|
## 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.
|
||||||
|
|||||||
+39
-21
@@ -1,19 +1,22 @@
|
|||||||
FROM php:8.3-cli-alpine AS base
|
#syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Runtime dependencies
|
FROM dunglas/frankenphp:1-php8.4-alpine AS base
|
||||||
RUN apk add --no-cache \
|
|
||||||
curl \
|
|
||||||
icu-libs \
|
|
||||||
libzip \
|
|
||||||
&& docker-php-ext-install opcache
|
|
||||||
|
|
||||||
# Composer
|
RUN apk add --no-cache icu-dev libzip-dev \
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
&& docker-php-ext-install -j$(nproc) intl opcache zip \
|
||||||
|
&& apk del icu-dev libzip-dev \
|
||||||
|
&& apk add --no-cache curl icu-libs libzip \
|
||||||
|
&& cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
||||||
|
&& mkdir -p $PHP_INI_DIR/app.conf.d
|
||||||
|
|
||||||
|
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
|
||||||
|
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# ── deps stage ────────────────────────────────────────────────────────────────
|
# ── deps stage (prod vendor) ───────────────────────────────────────────────────
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
COPY composer.json composer.lock* ./
|
COPY composer.json composer.lock* ./
|
||||||
RUN composer install \
|
RUN composer install \
|
||||||
--no-dev \
|
--no-dev \
|
||||||
@@ -22,20 +25,35 @@ RUN composer install \
|
|||||||
--optimize-autoloader \
|
--optimize-autoloader \
|
||||||
--prefer-dist
|
--prefer-dist
|
||||||
|
|
||||||
# ── final stage ───────────────────────────────────────────────────────────────
|
# ── build stage (generate optimised classmap with source present) ──────────────
|
||||||
|
FROM deps AS build
|
||||||
|
COPY . .
|
||||||
|
RUN composer dump-autoload --classmap-authoritative --no-dev --no-interaction && \
|
||||||
|
mkdir -p var/cache var/log && \
|
||||||
|
APP_ENV=prod APP_SECRET=placeholder php bin/console cache:warmup --no-debug
|
||||||
|
|
||||||
|
# ── final (prod) stage — no composer binary ────────────────────────────────────
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
|
|
||||||
COPY --from=deps /app/vendor /app/vendor
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN mkdir -p var/cache var/log \
|
COPY --link --from=build /app/vendor /app/vendor
|
||||||
&& chmod -R 777 var \
|
COPY --link --from=build /app/var/cache/prod /app/var/cache/prod
|
||||||
&& composer dump-autoload --optimize --no-dev
|
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 chmod +x bin/console && \
|
||||||
|
mkdir -p var/cache/prod/pools var/log var/data /config/caddy /data/caddy && \
|
||||||
|
chown -R app:app /app /config /data
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
ENV APP_ENV=prod APP_DEBUG=0
|
||||||
|
|
||||||
ENV APP_ENV=prod \
|
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
|
||||||
APP_DEBUG=0
|
|
||||||
|
|
||||||
# Symfony's built-in dev server via PHP — fine for a single-purpose container
|
|
||||||
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"]
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM dunglas/frankenphp:1-php8.4-alpine AS dev
|
||||||
|
|
||||||
|
RUN apk add --no-cache icu-dev libzip-dev \
|
||||||
|
&& docker-php-ext-install -j$(nproc) intl opcache zip \
|
||||||
|
&& apk del icu-dev libzip-dev \
|
||||||
|
&& apk add --no-cache curl icu-libs libzip \
|
||||||
|
&& cp "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" \
|
||||||
|
&& mkdir -p $PHP_INI_DIR/app.conf.d
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
RUN apk add --no-cache ${PHPIZE_DEPS} linux-headers \
|
||||||
|
&& pecl install xdebug \
|
||||||
|
&& docker-php-ext-enable xdebug \
|
||||||
|
&& apk del ${PHPIZE_DEPS}
|
||||||
|
|
||||||
|
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
|
||||||
|
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-xdebug.ini
|
||||||
|
COPY docker/php/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/
|
||||||
|
COPY docker/frankenphp/Caddyfile.dev /etc/caddy/Caddyfile
|
||||||
|
COPY composer.json composer.lock* ./
|
||||||
|
RUN composer install --no-scripts --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV APP_ENV=dev APP_DEBUG=1
|
||||||
|
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
|
||||||
@@ -6,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
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -104,11 +144,14 @@ All credentials are configured via environment variables — see [Deploy](#deplo
|
|||||||
|
|
||||||
### GitHub
|
### GitHub
|
||||||
|
|
||||||
|
**Fine-grained token (recommended):**
|
||||||
1. Go to **Settings → Developer settings → Personal access tokens → Fine-grained tokens**
|
1. Go to **Settings → Developer settings → Personal access tokens → Fine-grained tokens**
|
||||||
2. Set **Resource owner** to your account
|
2. Set **Resource owner** to your account
|
||||||
3. Under **Permissions → Account permissions**, set **Contribution activity** → Read-only
|
3. Under **Permissions → Account permissions**, set **Contribution activity** → Read-only
|
||||||
4. Generate and copy the token
|
4. Generate and copy the token
|
||||||
|
|
||||||
|
**Classic token:** create a token with the `read:user` scope.
|
||||||
|
|
||||||
### GitLab
|
### GitLab
|
||||||
|
|
||||||
1. Go to **User Settings → Access Tokens**
|
1. Go to **User Settings → Access Tokens**
|
||||||
@@ -125,6 +168,8 @@ All credentials are configured via environment variables — see [Deploy](#deplo
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
### Local (PHP)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install deps
|
# Install deps
|
||||||
composer install
|
composer install
|
||||||
@@ -136,24 +181,114 @@ APP_ENV=dev php -S localhost:8080 -t public
|
|||||||
curl "http://localhost:8080/graph.svg" -o graph.svg
|
curl "http://localhost:8080/graph.svg" -o graph.svg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
`docker-compose.override.yml` is picked up automatically and targets the `dev` stage (Xdebug enabled, source mounted).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev container
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Shell into the container
|
||||||
|
docker compose exec graph sh
|
||||||
|
|
||||||
|
# Run tests inside the container
|
||||||
|
docker compose exec graph vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Disable Xdebug for faster test runs
|
||||||
|
XDEBUG_MODE=off docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Xdebug listens on port **9003**. On Linux, `host.docker.internal` is resolved via `host-gateway`.
|
||||||
|
|
||||||
|
To run with production config only (no override):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run full suite
|
||||||
|
vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Human-readable output
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
|
||||||
|
# Single file
|
||||||
|
vendor/bin/phpunit tests/Unit/Service/Renderer/SvgRendererTest.php
|
||||||
|
|
||||||
|
# Filter by name
|
||||||
|
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
|
GET /graph.svg?theme=dark|light
|
||||||
│
|
└─ GraphController
|
||||||
├─ GitHubProvider → GitHub GraphQL API (contributionCalendar)
|
├─ host check (ALLOWED_HOSTS env, optional)
|
||||||
├─ GitLabProvider → GitLab REST API (/users/:id/events)
|
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
|
||||||
└─ GiteaProvider → Gitea REST API (/users/:user/heatmap)
|
│ └─ on miss: ContributionAggregator::aggregate()
|
||||||
│
|
│ ├─ per configured provider: ContributionStore::latestDate($name)
|
||||||
merge by date (sum counts)
|
│ │ → $since = latest - 3 days (trailing overlap), or null on first run
|
||||||
│
|
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar query, bounded by $since/$until)
|
||||||
SvgRenderer
|
│ ├─ GitLabProvider → GitLab REST /users/:id/events (paginated, 100/page, `after`/`before` bounded)
|
||||||
│
|
│ ├─ GiteaProvider → Gitea REST /api/v1/users/:user/heatmap (filtered client-side by $since/$until)
|
||||||
image/svg+xml (cached 1h)
|
│ │ 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()
|
||||||
|
└─ Response: image/svg+xml, Cache-Control: public max-age=3600
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Provider activation:** a provider only runs when its env vars are non-empty. GitHub and GitLab require `_USER` + `_TOKEN`; Gitea additionally requires `_URL`. GitLab resolves a numeric user ID from the username via a `/api/v4/users?username=` lookup before fetching events.
|
||||||
|
|
||||||
|
**SvgRenderer:** builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 → level 0, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
+1
-1
@@ -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';
|
||||||
|
|||||||
+19
-7
@@ -5,21 +5,30 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"symfony/cache": "^7.1",
|
"monolog/monolog": "^3.10",
|
||||||
"symfony/framework-bundle": "^7.1",
|
"symfony/cache": "7.4.*",
|
||||||
"symfony/http-client": "^7.1",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/runtime": "^7.1"
|
"symfony/framework-bundle": "7.4.*",
|
||||||
|
"symfony/http-client": "7.4.*",
|
||||||
|
"symfony/monolog-bundle": "^4.0",
|
||||||
|
"symfony/runtime": "7.4.*",
|
||||||
|
"symfony/yaml": "7.4.*"
|
||||||
},
|
},
|
||||||
"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": {
|
||||||
|
"phpstan/phpstan": "^2.2",
|
||||||
|
"phpunit/phpunit": "^11.5",
|
||||||
|
"symfony/phpunit-bridge": "^7.4"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
@@ -30,7 +39,10 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.1.*"
|
"require": "7.4.*"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"phpstan": "phpstan analyse --memory-limit=512M"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5012
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
monolog:
|
||||||
|
channels:
|
||||||
|
- deprecation
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: stream
|
||||||
|
path: '%kernel.logs_dir%/%kernel.environment%.log'
|
||||||
|
level: debug
|
||||||
|
channels: ['!event']
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: stream
|
||||||
|
path: php://stderr
|
||||||
|
level: warning
|
||||||
|
channels: ['!event']
|
||||||
|
deprecation:
|
||||||
|
type: stream
|
||||||
|
channels: [deprecation]
|
||||||
|
path: '%kernel.logs_dir%/%kernel.environment%.deprecations.log'
|
||||||
|
level: info
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||||
|
|
||||||
|
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||||
|
|
||||||
|
use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class provides array-shapes for configuring the services and bundles of an application.
|
||||||
|
*
|
||||||
|
* Services declared with the config() method below are autowired and autoconfigured by default.
|
||||||
|
*
|
||||||
|
* This is for apps only. Bundles SHOULD NOT use it.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* // config/services.php
|
||||||
|
* namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||||
|
*
|
||||||
|
* return App::config([
|
||||||
|
* 'services' => [
|
||||||
|
* 'App\\' => [
|
||||||
|
* 'resource' => '../src/',
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ]);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @psalm-type ImportsConfig = list<string|array{
|
||||||
|
* resource: string,
|
||||||
|
* type?: string|null,
|
||||||
|
* ignore_errors?: bool,
|
||||||
|
* }>
|
||||||
|
* @psalm-type ParametersConfig = array<string, scalar|\UnitEnum|array<scalar|\UnitEnum|array<mixed>|Param|null>|Param|null>
|
||||||
|
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
|
||||||
|
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
|
||||||
|
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key
|
||||||
|
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator
|
||||||
|
* @psalm-type DeprecationType = array{package: string, version: string, message?: string}
|
||||||
|
* @psalm-type DefaultsType = array{
|
||||||
|
* public?: bool,
|
||||||
|
* tags?: TagsType,
|
||||||
|
* resource_tags?: TagsType,
|
||||||
|
* autowire?: bool,
|
||||||
|
* autoconfigure?: bool,
|
||||||
|
* bind?: array<string, mixed>,
|
||||||
|
* }
|
||||||
|
* @psalm-type InstanceofType = array{
|
||||||
|
* shared?: bool,
|
||||||
|
* lazy?: bool|string,
|
||||||
|
* public?: bool,
|
||||||
|
* properties?: array<string, mixed>,
|
||||||
|
* configurator?: CallbackType,
|
||||||
|
* calls?: list<CallType>,
|
||||||
|
* tags?: TagsType,
|
||||||
|
* resource_tags?: TagsType,
|
||||||
|
* autowire?: bool,
|
||||||
|
* bind?: array<string, mixed>,
|
||||||
|
* constructor?: string,
|
||||||
|
* }
|
||||||
|
* @psalm-type DefinitionType = array{
|
||||||
|
* class?: string,
|
||||||
|
* file?: string,
|
||||||
|
* parent?: string,
|
||||||
|
* shared?: bool,
|
||||||
|
* synthetic?: bool,
|
||||||
|
* lazy?: bool|string,
|
||||||
|
* public?: bool,
|
||||||
|
* abstract?: bool,
|
||||||
|
* deprecated?: DeprecationType,
|
||||||
|
* factory?: CallbackType,
|
||||||
|
* configurator?: CallbackType,
|
||||||
|
* arguments?: ArgumentsType,
|
||||||
|
* properties?: array<string, mixed>,
|
||||||
|
* calls?: list<CallType>,
|
||||||
|
* tags?: TagsType,
|
||||||
|
* resource_tags?: TagsType,
|
||||||
|
* decorates?: string,
|
||||||
|
* decoration_inner_name?: string,
|
||||||
|
* decoration_priority?: int,
|
||||||
|
* decoration_on_invalid?: 'exception'|'ignore'|null,
|
||||||
|
* autowire?: bool,
|
||||||
|
* autoconfigure?: bool,
|
||||||
|
* bind?: array<string, mixed>,
|
||||||
|
* constructor?: string,
|
||||||
|
* from_callable?: CallbackType,
|
||||||
|
* }
|
||||||
|
* @psalm-type AliasType = string|array{
|
||||||
|
* alias: string,
|
||||||
|
* public?: bool,
|
||||||
|
* deprecated?: DeprecationType,
|
||||||
|
* }
|
||||||
|
* @psalm-type PrototypeType = array{
|
||||||
|
* resource: string,
|
||||||
|
* namespace?: string,
|
||||||
|
* exclude?: string|list<string>,
|
||||||
|
* parent?: string,
|
||||||
|
* shared?: bool,
|
||||||
|
* lazy?: bool|string,
|
||||||
|
* public?: bool,
|
||||||
|
* abstract?: bool,
|
||||||
|
* deprecated?: DeprecationType,
|
||||||
|
* factory?: CallbackType,
|
||||||
|
* arguments?: ArgumentsType,
|
||||||
|
* properties?: array<string, mixed>,
|
||||||
|
* configurator?: CallbackType,
|
||||||
|
* calls?: list<CallType>,
|
||||||
|
* tags?: TagsType,
|
||||||
|
* resource_tags?: TagsType,
|
||||||
|
* autowire?: bool,
|
||||||
|
* autoconfigure?: bool,
|
||||||
|
* bind?: array<string, mixed>,
|
||||||
|
* constructor?: string,
|
||||||
|
* }
|
||||||
|
* @psalm-type StackType = array{
|
||||||
|
* stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,
|
||||||
|
* public?: bool,
|
||||||
|
* deprecated?: DeprecationType,
|
||||||
|
* }
|
||||||
|
* @psalm-type ServicesConfig = array{
|
||||||
|
* _defaults?: DefaultsType,
|
||||||
|
* _instanceof?: array<class-string, InstanceofType>,
|
||||||
|
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
|
||||||
|
* }
|
||||||
|
* @psalm-type ExtensionType = array<string, mixed>
|
||||||
|
* @psalm-type FrameworkConfig = array{
|
||||||
|
* secret?: scalar|Param|null,
|
||||||
|
* http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false
|
||||||
|
* allowed_http_method_override?: null|list<string|Param>,
|
||||||
|
* trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%"
|
||||||
|
* ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%"
|
||||||
|
* test?: bool|Param,
|
||||||
|
* default_locale?: scalar|Param|null, // Default: "en"
|
||||||
|
* set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false
|
||||||
|
* set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false
|
||||||
|
* enabled_locales?: list<scalar|Param|null>,
|
||||||
|
* trusted_hosts?: string|list<scalar|Param|null>,
|
||||||
|
* trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"]
|
||||||
|
* trusted_headers?: string|list<scalar|Param|null>,
|
||||||
|
* error_controller?: scalar|Param|null, // Default: "error_controller"
|
||||||
|
* handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true
|
||||||
|
* csrf_protection?: bool|array{
|
||||||
|
* enabled?: scalar|Param|null, // Default: null
|
||||||
|
* stateless_token_ids?: list<scalar|Param|null>,
|
||||||
|
* check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false
|
||||||
|
* cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
|
||||||
|
* },
|
||||||
|
* form?: bool|array{ // Form configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* csrf_protection?: bool|array{
|
||||||
|
* enabled?: scalar|Param|null, // Default: null
|
||||||
|
* token_id?: scalar|Param|null, // Default: null
|
||||||
|
* field_name?: scalar|Param|null, // Default: "_token"
|
||||||
|
* field_attr?: array<string, scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* http_cache?: bool|array{ // HTTP cache configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* debug?: bool|Param, // Default: "%kernel.debug%"
|
||||||
|
* trace_level?: "none"|"short"|"full"|Param,
|
||||||
|
* trace_header?: scalar|Param|null,
|
||||||
|
* default_ttl?: int|Param,
|
||||||
|
* private_headers?: list<scalar|Param|null>,
|
||||||
|
* skip_response_headers?: list<scalar|Param|null>,
|
||||||
|
* allow_reload?: bool|Param,
|
||||||
|
* allow_revalidate?: bool|Param,
|
||||||
|
* stale_while_revalidate?: int|Param,
|
||||||
|
* stale_if_error?: int|Param,
|
||||||
|
* terminate_on_cache_hit?: bool|Param,
|
||||||
|
* },
|
||||||
|
* esi?: bool|array{ // ESI configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* ssi?: bool|array{ // SSI configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* fragments?: bool|array{ // Fragments configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* hinclude_default_template?: scalar|Param|null, // Default: null
|
||||||
|
* path?: scalar|Param|null, // Default: "/_fragment"
|
||||||
|
* },
|
||||||
|
* profiler?: bool|array{ // Profiler configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* collect?: bool|Param, // Default: true
|
||||||
|
* collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null
|
||||||
|
* only_exceptions?: bool|Param, // Default: false
|
||||||
|
* only_main_requests?: bool|Param, // Default: false
|
||||||
|
* dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler"
|
||||||
|
* collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false
|
||||||
|
* },
|
||||||
|
* workflows?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* workflows?: array<string, array{ // Default: []
|
||||||
|
* audit_trail?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* type?: "workflow"|"state_machine"|Param, // Default: "state_machine"
|
||||||
|
* marking_store?: array{
|
||||||
|
* type?: "method"|Param,
|
||||||
|
* property?: scalar|Param|null,
|
||||||
|
* service?: scalar|Param|null,
|
||||||
|
* },
|
||||||
|
* supports?: string|list<scalar|Param|null>,
|
||||||
|
* definition_validators?: list<scalar|Param|null>,
|
||||||
|
* support_strategy?: scalar|Param|null,
|
||||||
|
* initial_marking?: \BackedEnum|string|list<scalar|Param|null>,
|
||||||
|
* events_to_dispatch?: null|list<string|Param>,
|
||||||
|
* places?: string|list<array{ // Default: []
|
||||||
|
* name?: scalar|Param|null,
|
||||||
|
* metadata?: array<string, mixed>,
|
||||||
|
* }>,
|
||||||
|
* transitions?: list<array{ // Default: []
|
||||||
|
* name?: string|Param,
|
||||||
|
* guard?: string|Param, // An expression to block the transition.
|
||||||
|
* from?: \BackedEnum|string|list<array{ // Default: []
|
||||||
|
* place?: string|Param,
|
||||||
|
* weight?: int|Param, // Default: 1
|
||||||
|
* }>,
|
||||||
|
* to?: \BackedEnum|string|list<array{ // Default: []
|
||||||
|
* place?: string|Param,
|
||||||
|
* weight?: int|Param, // Default: 1
|
||||||
|
* }>,
|
||||||
|
* weight?: int|Param, // Default: 1
|
||||||
|
* metadata?: array<string, mixed>,
|
||||||
|
* }>,
|
||||||
|
* metadata?: array<string, mixed>,
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* router?: bool|array{ // Router configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* resource?: scalar|Param|null,
|
||||||
|
* type?: scalar|Param|null,
|
||||||
|
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
|
||||||
|
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||||
|
* http_port?: scalar|Param|null, // Default: 80
|
||||||
|
* https_port?: scalar|Param|null, // Default: 443
|
||||||
|
* strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true
|
||||||
|
* utf8?: bool|Param, // Default: true
|
||||||
|
* },
|
||||||
|
* session?: bool|array{ // Session configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native"
|
||||||
|
* handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.
|
||||||
|
* name?: scalar|Param|null,
|
||||||
|
* cookie_lifetime?: scalar|Param|null,
|
||||||
|
* cookie_path?: scalar|Param|null,
|
||||||
|
* cookie_domain?: scalar|Param|null,
|
||||||
|
* cookie_secure?: true|false|"auto"|Param, // Default: "auto"
|
||||||
|
* cookie_httponly?: bool|Param, // Default: true
|
||||||
|
* cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
|
||||||
|
* use_cookies?: bool|Param,
|
||||||
|
* gc_divisor?: scalar|Param|null,
|
||||||
|
* gc_probability?: scalar|Param|null,
|
||||||
|
* gc_maxlifetime?: scalar|Param|null,
|
||||||
|
* save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null.
|
||||||
|
* metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0
|
||||||
|
* sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||||
|
* sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||||
|
* },
|
||||||
|
* request?: bool|array{ // Request configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* formats?: array<string, string|list<scalar|Param|null>>,
|
||||||
|
* },
|
||||||
|
* assets?: bool|array{ // Assets configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||||
|
* version_strategy?: scalar|Param|null, // Default: null
|
||||||
|
* version?: scalar|Param|null, // Default: null
|
||||||
|
* version_format?: scalar|Param|null, // Default: "%%s?%%s"
|
||||||
|
* json_manifest_path?: scalar|Param|null, // Default: null
|
||||||
|
* base_path?: scalar|Param|null, // Default: ""
|
||||||
|
* base_urls?: string|list<scalar|Param|null>,
|
||||||
|
* packages?: array<string, array{ // Default: []
|
||||||
|
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||||
|
* version_strategy?: scalar|Param|null, // Default: null
|
||||||
|
* version?: scalar|Param|null,
|
||||||
|
* version_format?: scalar|Param|null, // Default: null
|
||||||
|
* json_manifest_path?: scalar|Param|null, // Default: null
|
||||||
|
* base_path?: scalar|Param|null, // Default: ""
|
||||||
|
* base_urls?: string|list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* asset_mapper?: bool|array{ // Asset Mapper configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* paths?: string|array<string, scalar|Param|null>,
|
||||||
|
* excluded_patterns?: list<scalar|Param|null>,
|
||||||
|
* exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
|
||||||
|
* server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true
|
||||||
|
* public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/"
|
||||||
|
* missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn"
|
||||||
|
* extensions?: array<string, scalar|Param|null>,
|
||||||
|
* importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php"
|
||||||
|
* importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims"
|
||||||
|
* importmap_script_attributes?: array<string, scalar|Param|null>,
|
||||||
|
* vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor"
|
||||||
|
* precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip.
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* formats?: list<scalar|Param|null>,
|
||||||
|
* extensions?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* translator?: bool|array{ // Translator configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* fallbacks?: string|list<scalar|Param|null>,
|
||||||
|
* logging?: bool|Param, // Default: false
|
||||||
|
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
||||||
|
* cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations"
|
||||||
|
* default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations"
|
||||||
|
* paths?: list<scalar|Param|null>,
|
||||||
|
* pseudo_localization?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* accents?: bool|Param, // Default: true
|
||||||
|
* expansion_factor?: float|Param, // Default: 1.0
|
||||||
|
* brackets?: bool|Param, // Default: true
|
||||||
|
* parse_html?: bool|Param, // Default: false
|
||||||
|
* localizable_html_attributes?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* providers?: array<string, array{ // Default: []
|
||||||
|
* dsn?: scalar|Param|null,
|
||||||
|
* domains?: list<scalar|Param|null>,
|
||||||
|
* locales?: list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* globals?: array<string, string|array{ // Default: []
|
||||||
|
* value?: mixed,
|
||||||
|
* message?: string|Param,
|
||||||
|
* parameters?: array<string, scalar|Param|null>,
|
||||||
|
* domain?: string|Param,
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* validation?: bool|array{ // Validation configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0.
|
||||||
|
* enable_attributes?: bool|Param, // Default: true
|
||||||
|
* static_method?: string|list<scalar|Param|null>,
|
||||||
|
* translation_domain?: scalar|Param|null, // Default: "validators"
|
||||||
|
* email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5"
|
||||||
|
* mapping?: array{
|
||||||
|
* paths?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* not_compromised_password?: bool|array{
|
||||||
|
* enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true
|
||||||
|
* endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null
|
||||||
|
* },
|
||||||
|
* disable_translation?: bool|Param, // Default: false
|
||||||
|
* auto_mapping?: array<string, array{ // Default: []
|
||||||
|
* services?: list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* annotations?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* serializer?: bool|array{ // Serializer configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* enable_attributes?: bool|Param, // Default: true
|
||||||
|
* name_converter?: scalar|Param|null,
|
||||||
|
* circular_reference_handler?: scalar|Param|null,
|
||||||
|
* max_depth_handler?: scalar|Param|null,
|
||||||
|
* mapping?: array{
|
||||||
|
* paths?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* default_context?: array<string, mixed>,
|
||||||
|
* named_serializers?: array<string, array{ // Default: []
|
||||||
|
* name_converter?: scalar|Param|null,
|
||||||
|
* default_context?: array<string, mixed>,
|
||||||
|
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
|
||||||
|
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* property_access?: bool|array{ // Property access configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* magic_call?: bool|Param, // Default: false
|
||||||
|
* magic_get?: bool|Param, // Default: true
|
||||||
|
* magic_set?: bool|Param, // Default: true
|
||||||
|
* throw_exception_on_invalid_index?: bool|Param, // Default: false
|
||||||
|
* throw_exception_on_invalid_property_path?: bool|Param, // Default: true
|
||||||
|
* },
|
||||||
|
* type_info?: bool|array{ // Type info configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* aliases?: array<string, scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* property_info?: bool|array{ // Property info configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* with_constructor_extractor?: bool|Param, // Registers the constructor extractor.
|
||||||
|
* },
|
||||||
|
* cache?: array{ // Cache configuration
|
||||||
|
* prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%"
|
||||||
|
* app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem"
|
||||||
|
* system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system"
|
||||||
|
* directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app"
|
||||||
|
* default_psr6_provider?: scalar|Param|null,
|
||||||
|
* default_redis_provider?: scalar|Param|null, // Default: "redis://localhost"
|
||||||
|
* default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost"
|
||||||
|
* default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost"
|
||||||
|
* default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection"
|
||||||
|
* default_pdo_provider?: scalar|Param|null, // Default: null
|
||||||
|
* pools?: array<string, array{ // Default: []
|
||||||
|
* adapters?: string|list<scalar|Param|null>,
|
||||||
|
* tags?: scalar|Param|null, // Default: null
|
||||||
|
* public?: bool|Param, // Default: false
|
||||||
|
* default_lifetime?: scalar|Param|null, // Default lifetime of the pool.
|
||||||
|
* provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter.
|
||||||
|
* early_expiration_message_bus?: scalar|Param|null,
|
||||||
|
* clearer?: scalar|Param|null,
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* php_errors?: array{ // PHP errors handling configuration
|
||||||
|
* log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true
|
||||||
|
* throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true
|
||||||
|
* },
|
||||||
|
* exceptions?: array<string, array{ // Default: []
|
||||||
|
* log_level?: scalar|Param|null, // The level of log message. Null to let Symfony decide. // Default: null
|
||||||
|
* status_code?: scalar|Param|null, // The status code of the response. Null or 0 to let Symfony decide. // Default: null
|
||||||
|
* log_channel?: scalar|Param|null, // The channel of log message. Null to let Symfony decide. // Default: null
|
||||||
|
* }>,
|
||||||
|
* web_link?: bool|array{ // Web links configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* lock?: bool|string|array{ // Lock configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* resources?: string|array<string, string|list<scalar|Param|null>>,
|
||||||
|
* },
|
||||||
|
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* resources?: string|array<string, scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* messenger?: bool|array{ // Messenger configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* routing?: array<string, string|array{ // Default: []
|
||||||
|
* senders?: list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* serializer?: array{
|
||||||
|
* default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
|
||||||
|
* symfony_serializer?: array{
|
||||||
|
* format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json"
|
||||||
|
* context?: array<string, mixed>,
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* transports?: array<string, string|array{ // Default: []
|
||||||
|
* dsn?: scalar|Param|null,
|
||||||
|
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
|
||||||
|
* options?: array<string, mixed>,
|
||||||
|
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||||
|
* retry_strategy?: string|array{
|
||||||
|
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
|
||||||
|
* max_retries?: int|Param, // Default: 3
|
||||||
|
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||||
|
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2
|
||||||
|
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||||
|
* jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1
|
||||||
|
* },
|
||||||
|
* rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null
|
||||||
|
* }>,
|
||||||
|
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||||
|
* stop_worker_on_signals?: int|string|list<scalar|Param|null>,
|
||||||
|
* default_bus?: scalar|Param|null, // Default: null
|
||||||
|
* buses?: array<string, array{ // Default: {"messenger.bus.default":{"default_middleware":{"enabled":true,"allow_no_handlers":false,"allow_no_senders":true},"middleware":[]}}
|
||||||
|
* default_middleware?: bool|string|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* allow_no_handlers?: bool|Param, // Default: false
|
||||||
|
* allow_no_senders?: bool|Param, // Default: true
|
||||||
|
* },
|
||||||
|
* middleware?: string|list<string|array{ // Default: []
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* arguments?: list<mixed>,
|
||||||
|
* }>,
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* scheduler?: bool|array{ // Scheduler configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||||
|
* http_client?: bool|array{ // HTTP Client configuration
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||||
|
* default_options?: array{
|
||||||
|
* headers?: array<string, mixed>,
|
||||||
|
* vars?: array<string, mixed>,
|
||||||
|
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
||||||
|
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||||
|
* resolve?: array<string, scalar|Param|null>,
|
||||||
|
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||||
|
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||||
|
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||||
|
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
||||||
|
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||||
|
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
||||||
|
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
||||||
|
* cafile?: scalar|Param|null, // A certificate authority file.
|
||||||
|
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
|
||||||
|
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
|
||||||
|
* local_pk?: scalar|Param|null, // A private key file.
|
||||||
|
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
|
||||||
|
* ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)
|
||||||
|
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||||
|
* sha1?: mixed,
|
||||||
|
* pin-sha256?: mixed,
|
||||||
|
* md5?: mixed,
|
||||||
|
* },
|
||||||
|
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||||
|
* extra?: array<string, mixed>,
|
||||||
|
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||||
|
* caching?: bool|array{ // Caching configuration.
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||||
|
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||||
|
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||||
|
* },
|
||||||
|
* retry_failed?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
|
||||||
|
* http_codes?: int|string|array<string, array{ // Default: []
|
||||||
|
* code?: int|Param,
|
||||||
|
* methods?: string|list<string|Param>,
|
||||||
|
* }>,
|
||||||
|
* max_retries?: int|Param, // Default: 3
|
||||||
|
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||||
|
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||||
|
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||||
|
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.
|
||||||
|
* scoped_clients?: array<string, string|array{ // Default: []
|
||||||
|
* scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.
|
||||||
|
* base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
|
||||||
|
* auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password".
|
||||||
|
* auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization.
|
||||||
|
* auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).
|
||||||
|
* query?: array<string, scalar|Param|null>,
|
||||||
|
* headers?: array<string, mixed>,
|
||||||
|
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
||||||
|
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||||
|
* resolve?: array<string, scalar|Param|null>,
|
||||||
|
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||||
|
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||||
|
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||||
|
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
||||||
|
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||||
|
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
||||||
|
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
||||||
|
* cafile?: scalar|Param|null, // A certificate authority file.
|
||||||
|
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
|
||||||
|
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
|
||||||
|
* local_pk?: scalar|Param|null, // A private key file.
|
||||||
|
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
|
||||||
|
* ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...).
|
||||||
|
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||||
|
* sha1?: mixed,
|
||||||
|
* pin-sha256?: mixed,
|
||||||
|
* md5?: mixed,
|
||||||
|
* },
|
||||||
|
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||||
|
* extra?: array<string, mixed>,
|
||||||
|
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||||
|
* caching?: bool|array{ // Caching configuration.
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||||
|
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||||
|
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||||
|
* },
|
||||||
|
* retry_failed?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
|
||||||
|
* http_codes?: int|string|array<string, array{ // Default: []
|
||||||
|
* code?: int|Param,
|
||||||
|
* methods?: string|list<string|Param>,
|
||||||
|
* }>,
|
||||||
|
* max_retries?: int|Param, // Default: 3
|
||||||
|
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||||
|
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||||
|
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||||
|
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||||
|
* },
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* mailer?: bool|array{ // Mailer configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||||
|
* dsn?: scalar|Param|null, // Default: null
|
||||||
|
* transports?: array<string, scalar|Param|null>,
|
||||||
|
* envelope?: array{ // Mailer Envelope configuration
|
||||||
|
* sender?: scalar|Param|null,
|
||||||
|
* recipients?: string|list<scalar|Param|null>,
|
||||||
|
* allowed_recipients?: string|list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* headers?: array<string, string|array{ // Default: []
|
||||||
|
* value?: mixed,
|
||||||
|
* }>,
|
||||||
|
* dkim_signer?: bool|array{ // DKIM signer configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: ""
|
||||||
|
* domain?: scalar|Param|null, // Default: ""
|
||||||
|
* select?: scalar|Param|null, // Default: ""
|
||||||
|
* passphrase?: scalar|Param|null, // The private key passphrase // Default: ""
|
||||||
|
* options?: array<string, mixed>,
|
||||||
|
* },
|
||||||
|
* smime_signer?: bool|array{ // S/MIME signer configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* key?: scalar|Param|null, // Path to key (in PEM format) // Default: ""
|
||||||
|
* certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: ""
|
||||||
|
* passphrase?: scalar|Param|null, // The private key passphrase // Default: null
|
||||||
|
* extra_certificates?: scalar|Param|null, // Default: null
|
||||||
|
* sign_options?: int|Param, // Default: null
|
||||||
|
* },
|
||||||
|
* smime_encrypter?: bool|array{ // S/MIME encrypter configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: ""
|
||||||
|
* cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* secrets?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%"
|
||||||
|
* local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local"
|
||||||
|
* decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET"
|
||||||
|
* },
|
||||||
|
* notifier?: bool|array{ // Notifier configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||||
|
* chatter_transports?: array<string, scalar|Param|null>,
|
||||||
|
* texter_transports?: array<string, scalar|Param|null>,
|
||||||
|
* notification_on_failed_messages?: bool|Param, // Default: false
|
||||||
|
* channel_policy?: array<string, string|list<scalar|Param|null>>,
|
||||||
|
* admin_recipients?: list<array{ // Default: []
|
||||||
|
* email?: scalar|Param|null,
|
||||||
|
* phone?: scalar|Param|null, // Default: ""
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* limiters?: array<string, array{ // Default: []
|
||||||
|
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||||
|
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||||
|
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||||
|
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||||
|
* limiters?: string|list<scalar|Param|null>,
|
||||||
|
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||||
|
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||||
|
* rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket".
|
||||||
|
* interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||||
|
* amount?: int|Param, // Amount of tokens to add each interval. // Default: 1
|
||||||
|
* },
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* uid?: bool|array{ // Uid configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* default_uuid_version?: 7|6|4|1|Param, // Default: 7
|
||||||
|
* name_based_uuid_version?: 5|3|Param, // Default: 5
|
||||||
|
* name_based_uuid_namespace?: scalar|Param|null,
|
||||||
|
* time_based_uuid_version?: 7|6|1|Param, // Default: 7
|
||||||
|
* time_based_uuid_node?: scalar|Param|null,
|
||||||
|
* },
|
||||||
|
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* sanitizers?: array<string, array{ // Default: []
|
||||||
|
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
|
||||||
|
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
|
||||||
|
* allow_elements?: array<string, mixed>,
|
||||||
|
* block_elements?: string|list<string|Param>,
|
||||||
|
* drop_elements?: string|list<string|Param>,
|
||||||
|
* allow_attributes?: array<string, mixed>,
|
||||||
|
* drop_attributes?: array<string, mixed>,
|
||||||
|
* force_attributes?: array<string, array<string, string|Param>>,
|
||||||
|
* force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false
|
||||||
|
* allowed_link_schemes?: string|list<string|Param>,
|
||||||
|
* allowed_link_hosts?: null|string|list<string|Param>,
|
||||||
|
* allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false
|
||||||
|
* allowed_media_schemes?: string|list<string|Param>,
|
||||||
|
* allowed_media_hosts?: null|string|list<string|Param>,
|
||||||
|
* allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false
|
||||||
|
* with_attribute_sanitizers?: string|list<string|Param>,
|
||||||
|
* without_attribute_sanitizers?: string|list<string|Param>,
|
||||||
|
* max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* webhook?: bool|array{ // Webhook configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||||
|
* routing?: array<string, array{ // Default: []
|
||||||
|
* service?: scalar|Param|null,
|
||||||
|
* secret?: scalar|Param|null, // Default: ""
|
||||||
|
* }>,
|
||||||
|
* },
|
||||||
|
* remote-event?: bool|array{ // RemoteEvent configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* json_streamer?: bool|array{ // JSON streamer configuration
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* }
|
||||||
|
* @psalm-type MonologConfig = array{
|
||||||
|
* use_microseconds?: scalar|Param|null, // Default: true
|
||||||
|
* channels?: list<scalar|Param|null>,
|
||||||
|
* handlers?: array<string, array{ // Default: []
|
||||||
|
* type?: scalar|Param|null,
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* priority?: scalar|Param|null, // Default: 0
|
||||||
|
* level?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* bubble?: bool|Param, // Default: true
|
||||||
|
* interactive_only?: bool|Param, // Default: false
|
||||||
|
* app_name?: scalar|Param|null, // Default: null
|
||||||
|
* include_stacktraces?: bool|Param, // Default: false
|
||||||
|
* process_psr_3_messages?: array{
|
||||||
|
* enabled?: bool|Param|null, // Default: null
|
||||||
|
* date_format?: scalar|Param|null,
|
||||||
|
* remove_used_context_fields?: bool|Param,
|
||||||
|
* },
|
||||||
|
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
* file_permission?: scalar|Param|null, // Default: null
|
||||||
|
* use_locking?: bool|Param, // Default: false
|
||||||
|
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
|
||||||
|
* date_format?: scalar|Param|null, // Default: "Y-m-d"
|
||||||
|
* ident?: scalar|Param|null, // Default: false
|
||||||
|
* logopts?: scalar|Param|null, // Default: 1
|
||||||
|
* facility?: scalar|Param|null, // Default: "user"
|
||||||
|
* max_files?: scalar|Param|null, // Default: 0
|
||||||
|
* action_level?: scalar|Param|null, // Default: "WARNING"
|
||||||
|
* activation_strategy?: scalar|Param|null, // Default: null
|
||||||
|
* stop_buffering?: bool|Param, // Default: true
|
||||||
|
* passthru_level?: scalar|Param|null, // Default: null
|
||||||
|
* excluded_http_codes?: list<array{ // Default: []
|
||||||
|
* code?: scalar|Param|null,
|
||||||
|
* urls?: list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* accepted_levels?: list<scalar|Param|null>,
|
||||||
|
* min_level?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
|
||||||
|
* buffer_size?: scalar|Param|null, // Default: 0
|
||||||
|
* flush_on_overflow?: bool|Param, // Default: false
|
||||||
|
* handler?: scalar|Param|null,
|
||||||
|
* url?: scalar|Param|null,
|
||||||
|
* exchange?: scalar|Param|null,
|
||||||
|
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||||
|
* channel?: scalar|Param|null, // Default: null
|
||||||
|
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||||
|
* use_attachment?: scalar|Param|null, // Default: true
|
||||||
|
* use_short_attachment?: scalar|Param|null, // Default: false
|
||||||
|
* include_extra?: scalar|Param|null, // Default: false
|
||||||
|
* icon_emoji?: scalar|Param|null, // Default: null
|
||||||
|
* webhook_url?: scalar|Param|null,
|
||||||
|
* exclude_fields?: list<scalar|Param|null>,
|
||||||
|
* token?: scalar|Param|null,
|
||||||
|
* region?: scalar|Param|null,
|
||||||
|
* source?: scalar|Param|null,
|
||||||
|
* use_ssl?: bool|Param, // Default: true
|
||||||
|
* user?: mixed,
|
||||||
|
* title?: scalar|Param|null, // Default: null
|
||||||
|
* host?: scalar|Param|null, // Default: null
|
||||||
|
* port?: scalar|Param|null, // Default: 514
|
||||||
|
* config?: list<scalar|Param|null>,
|
||||||
|
* members?: list<scalar|Param|null>,
|
||||||
|
* connection_string?: scalar|Param|null,
|
||||||
|
* timeout?: scalar|Param|null,
|
||||||
|
* time?: scalar|Param|null, // Default: 60
|
||||||
|
* deduplication_level?: scalar|Param|null, // Default: 400
|
||||||
|
* store?: scalar|Param|null, // Default: null
|
||||||
|
* connection_timeout?: scalar|Param|null,
|
||||||
|
* persistent?: bool|Param,
|
||||||
|
* message_type?: scalar|Param|null, // Default: 0
|
||||||
|
* parse_mode?: scalar|Param|null, // Default: null
|
||||||
|
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||||
|
* disable_notification?: bool|Param|null, // Default: null
|
||||||
|
* split_long_messages?: bool|Param, // Default: false
|
||||||
|
* delay_between_messages?: bool|Param, // Default: false
|
||||||
|
* topic?: int|Param, // Default: null
|
||||||
|
* factor?: int|Param, // Default: 1
|
||||||
|
* tags?: string|list<scalar|Param|null>,
|
||||||
|
* console_formatter_options?: mixed, // Default: []
|
||||||
|
* formatter?: scalar|Param|null,
|
||||||
|
* nested?: bool|Param, // Default: false
|
||||||
|
* publisher?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* hostname?: scalar|Param|null,
|
||||||
|
* port?: scalar|Param|null, // Default: 12201
|
||||||
|
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||||
|
* encoder?: "json"|"compressed_json"|Param,
|
||||||
|
* },
|
||||||
|
* mongodb?: string|array{
|
||||||
|
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||||
|
* uri?: scalar|Param|null,
|
||||||
|
* username?: scalar|Param|null,
|
||||||
|
* password?: scalar|Param|null,
|
||||||
|
* database?: scalar|Param|null, // Default: "monolog"
|
||||||
|
* collection?: scalar|Param|null, // Default: "logs"
|
||||||
|
* },
|
||||||
|
* elasticsearch?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* hosts?: list<scalar|Param|null>,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* port?: scalar|Param|null, // Default: 9200
|
||||||
|
* transport?: scalar|Param|null, // Default: "Http"
|
||||||
|
* user?: scalar|Param|null, // Default: null
|
||||||
|
* password?: scalar|Param|null, // Default: null
|
||||||
|
* },
|
||||||
|
* index?: scalar|Param|null, // Default: "monolog"
|
||||||
|
* document_type?: scalar|Param|null, // Default: "logs"
|
||||||
|
* ignore_error?: scalar|Param|null, // Default: false
|
||||||
|
* redis?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* password?: scalar|Param|null, // Default: null
|
||||||
|
* port?: scalar|Param|null, // Default: 6379
|
||||||
|
* database?: scalar|Param|null, // Default: 0
|
||||||
|
* key_name?: scalar|Param|null, // Default: "monolog_redis"
|
||||||
|
* },
|
||||||
|
* predis?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* },
|
||||||
|
* from_email?: scalar|Param|null,
|
||||||
|
* to_email?: string|list<scalar|Param|null>,
|
||||||
|
* subject?: scalar|Param|null,
|
||||||
|
* content_type?: scalar|Param|null, // Default: null
|
||||||
|
* headers?: list<scalar|Param|null>,
|
||||||
|
* mailer?: scalar|Param|null, // Default: null
|
||||||
|
* email_prototype?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* method?: scalar|Param|null, // Default: null
|
||||||
|
* },
|
||||||
|
* verbosity_levels?: array{
|
||||||
|
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||||
|
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||||
|
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
|
||||||
|
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
|
||||||
|
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* },
|
||||||
|
* channels?: string|array{
|
||||||
|
* type?: scalar|Param|null,
|
||||||
|
* elements?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* }>,
|
||||||
|
* }
|
||||||
|
* @psalm-type ConfigType = array{
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* framework?: FrameworkConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
|
* "when@dev"?: array{
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* framework?: FrameworkConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
|
* },
|
||||||
|
* "when@prod"?: array{
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* framework?: FrameworkConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
|
* },
|
||||||
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* ...<string, ExtensionType>,
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
final class App
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ConfigType $config
|
||||||
|
*
|
||||||
|
* @psalm-return ConfigType
|
||||||
|
*/
|
||||||
|
public static function config(array $config): array
|
||||||
|
{
|
||||||
|
/** @var ConfigType $config */
|
||||||
|
$config = AppReference::config($config);
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Symfony\Component\Routing\Loader\Configurator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class provides array-shapes for configuring the routes of an application.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* // config/routes.php
|
||||||
|
* namespace Symfony\Component\Routing\Loader\Configurator;
|
||||||
|
*
|
||||||
|
* return Routes::config([
|
||||||
|
* 'controllers' => [
|
||||||
|
* 'resource' => 'routing.controllers',
|
||||||
|
* ],
|
||||||
|
* ]);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @psalm-type RouteConfig = array{
|
||||||
|
* path: string|array<string,string>,
|
||||||
|
* controller?: string,
|
||||||
|
* methods?: string|list<string>,
|
||||||
|
* requirements?: array<string,string>,
|
||||||
|
* defaults?: array<string,mixed>,
|
||||||
|
* options?: array<string,mixed>,
|
||||||
|
* host?: string|array<string,string>,
|
||||||
|
* schemes?: string|list<string>,
|
||||||
|
* condition?: string,
|
||||||
|
* locale?: string,
|
||||||
|
* format?: string,
|
||||||
|
* utf8?: bool,
|
||||||
|
* stateless?: bool,
|
||||||
|
* }
|
||||||
|
* @psalm-type ImportConfig = array{
|
||||||
|
* resource: string,
|
||||||
|
* type?: string,
|
||||||
|
* exclude?: string|list<string>,
|
||||||
|
* prefix?: string|array<string,string>,
|
||||||
|
* name_prefix?: string,
|
||||||
|
* trailing_slash_on_root?: bool,
|
||||||
|
* controller?: string,
|
||||||
|
* methods?: string|list<string>,
|
||||||
|
* requirements?: array<string,string>,
|
||||||
|
* defaults?: array<string,mixed>,
|
||||||
|
* options?: array<string,mixed>,
|
||||||
|
* host?: string|array<string,string>,
|
||||||
|
* schemes?: string|list<string>,
|
||||||
|
* condition?: string,
|
||||||
|
* locale?: string,
|
||||||
|
* format?: string,
|
||||||
|
* utf8?: bool,
|
||||||
|
* stateless?: bool,
|
||||||
|
* }
|
||||||
|
* @psalm-type AliasConfig = array{
|
||||||
|
* alias: string,
|
||||||
|
* deprecated?: array{package:string, version:string, message?:string},
|
||||||
|
* }
|
||||||
|
* @psalm-type RoutesConfig = array{
|
||||||
|
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
|
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
|
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
final class Routes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param RoutesConfig $config
|
||||||
|
*
|
||||||
|
* @psalm-return RoutesConfig
|
||||||
|
*/
|
||||||
|
public static function config(array $config): array
|
||||||
|
{
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
controllers:
|
controllers:
|
||||||
resource:
|
resource:
|
||||||
path: ../src/Controller/
|
path: ../src/Controller/
|
||||||
namespace: App\Controller
|
namespace: GitContributionGraph\Controller
|
||||||
type: attribute
|
type: attribute
|
||||||
|
|||||||
@@ -0,0 +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:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
public: false
|
||||||
|
|
||||||
|
GitContributionGraph\:
|
||||||
|
resource: "../src/"
|
||||||
|
exclude:
|
||||||
|
- "../src/Kernel.php"
|
||||||
|
|
||||||
|
_instanceof:
|
||||||
|
GitContributionGraph\Service\Provider\ProviderInterface:
|
||||||
|
tags: ["app.provider"]
|
||||||
|
|
||||||
|
GitContributionGraph\Service\Provider\GitHubProvider:
|
||||||
|
arguments:
|
||||||
|
$username: "%env(GITHUB_USER)%"
|
||||||
|
$token: "%env(GITHUB_TOKEN)%"
|
||||||
|
|
||||||
|
GitContributionGraph\Service\Provider\GitLabProvider:
|
||||||
|
arguments:
|
||||||
|
$username: "%env(GITLAB_USER)%"
|
||||||
|
$token: "%env(GITLAB_TOKEN)%"
|
||||||
|
$baseUrl: "%env(GITLAB_URL)%"
|
||||||
|
|
||||||
|
GitContributionGraph\Service\Provider\GiteaProvider:
|
||||||
|
arguments:
|
||||||
|
$username: "%env(GITEA_USER)%"
|
||||||
|
$token: "%env(GITEA_TOKEN)%"
|
||||||
|
$baseUrl: "%env(GITEA_URL)%"
|
||||||
|
GitContributionGraph\Service\ContributionStore:
|
||||||
|
arguments:
|
||||||
|
$dbPath: "%kernel.project_dir%/var/data/contributions.db"
|
||||||
|
$retentionDays: "%env(int:CONTRIBUTIONS_RETENTION_DAYS)%"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Local development overrides — picked up automatically by `docker compose up`.
|
||||||
|
# To run with production config only: docker compose -f docker-compose.yml up
|
||||||
|
services:
|
||||||
|
graph:
|
||||||
|
build:
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
target: dev
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- ./vendor:/app/vendor
|
||||||
|
- /app/var/data
|
||||||
|
environment:
|
||||||
|
APP_ENV: dev
|
||||||
|
APP_DEBUG: "1"
|
||||||
|
XDEBUG_MODE: "${XDEBUG_MODE:-debug}"
|
||||||
|
XDEBUG_CONFIG: "client_host=host.docker.internal"
|
||||||
|
# Makes host.docker.internal resolve correctly on Linux
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
ports:
|
||||||
|
- "9003:9003"
|
||||||
@@ -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:
|
||||||
+8
-2
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
graph:
|
graph:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
target: final
|
||||||
container_name: git-contribution-graph
|
container_name: git-contribution-graph
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -18,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:
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
|
||||||
|
frankenphp {
|
||||||
|
worker /app/public/worker.php
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:8080 {
|
||||||
|
root * /app/public
|
||||||
|
php_server
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
debug
|
||||||
|
|
||||||
|
frankenphp
|
||||||
|
}
|
||||||
|
|
||||||
|
:8080 {
|
||||||
|
root * /app/public
|
||||||
|
php_server
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
opcache.validate_timestamps=1
|
||||||
|
opcache.revalidate_freq=0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[xdebug]
|
||||||
|
; Mode is controlled by the XDEBUG_MODE env var in docker-compose.override.yml.
|
||||||
|
; Set XDEBUG_MODE=off in your shell to skip Xdebug for faster test runs.
|
||||||
|
xdebug.mode=off
|
||||||
|
xdebug.client_port=9003
|
||||||
|
xdebug.client_host=host.docker.internal
|
||||||
|
xdebug.start_with_request=yes
|
||||||
|
xdebug.log=/tmp/xdebug.log
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
parameters:
|
||||||
|
level: 8
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- tests
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 539 B |
+1
-1
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -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();
|
||||||
@@ -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+)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,133 +1,106 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Controller;
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Service\GiteaProvider;
|
namespace GitContributionGraph\Controller;
|
||||||
use App\Service\GitHubProvider;
|
|
||||||
use App\Service\GitLabProvider;
|
use GitContributionGraph\Service\ContributionAggregator;
|
||||||
use App\Service\SvgRenderer;
|
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\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
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(
|
||||||
private readonly GitHubProvider $github,
|
private readonly ContributionAggregator $aggregator,
|
||||||
private readonly GitLabProvider $gitlab,
|
|
||||||
private readonly GiteaProvider $gitea,
|
|
||||||
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 = '',
|
||||||
#[Autowire(env: 'GITHUB_USER')]
|
|
||||||
private readonly string $githubUser = '',
|
|
||||||
#[Autowire(env: 'GITHUB_TOKEN')]
|
|
||||||
private readonly string $githubToken = '',
|
|
||||||
#[Autowire(env: 'GITLAB_USER')]
|
|
||||||
private readonly string $gitlabUser = '',
|
|
||||||
#[Autowire(env: 'GITLAB_TOKEN')]
|
|
||||||
private readonly string $gitlabToken = '',
|
|
||||||
#[Autowire(env: 'GITLAB_URL')]
|
|
||||||
private readonly string $gitlabUrl = '',
|
|
||||||
#[Autowire(env: 'GITEA_USER')]
|
|
||||||
private readonly string $giteaUser = '',
|
|
||||||
#[Autowire(env: 'GITEA_TOKEN')]
|
|
||||||
private readonly string $giteaToken = '',
|
|
||||||
#[Autowire(env: 'GITEA_URL')]
|
|
||||||
private readonly string $giteaUrl = '',
|
|
||||||
) {
|
) {
|
||||||
$this->allowedHosts = array_values(array_filter(array_map('trim', explode(',', $allowedHosts))));
|
$this->allowedHosts = array_values(array_filter(array_map('trim', explode(',', $allowedHosts))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
{
|
{
|
||||||
if ($this->allowedHosts !== [] && !in_array($request->getHost(), $this->allowedHosts, true)) {
|
if ($this->allowedHosts !== [] && !in_array($request->getHost(), $this->allowedHosts, true)) {
|
||||||
|
$this->logger->warning('GraphController: rejected request from disallowed host', ['host' => $request->getHost()]);
|
||||||
|
|
||||||
return new Response('Forbidden', 403);
|
return new Response('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$theme = $request->query->get('theme', 'dark');
|
$theme = $request->query->get('theme', 'dark');
|
||||||
|
|
||||||
$cacheKey = 'graph_' . $theme;
|
$cacheKey = 'graph_' . $theme;
|
||||||
|
|
||||||
$svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme): string {
|
$cacheMiss = false;
|
||||||
|
$svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string {
|
||||||
|
$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
|
||||||
|
|
||||||
$contributions = $this->fetchAllContributions();
|
return $this->renderer->render($this->aggregator->aggregate(), $theme);
|
||||||
|
|
||||||
return $this->renderer->render($contributions, $theme);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->logger->debug('GraphController: cache ' . ($cacheMiss ? 'miss' : 'hit'), ['theme' => $theme]);
|
||||||
|
|
||||||
return new Response($svg, 200, [
|
return new Response($svg, 200, [
|
||||||
'Content-Type' => 'image/svg+xml',
|
'Content-Type' => 'image/svg+xml',
|
||||||
'Cache-Control' => 'public, max-age=3600',
|
'Cache-Control' => 'public, max-age=3600',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects "/" to "/graph.svg", forwarding any query string (e.g. ?theme=light).
|
||||||
|
*
|
||||||
|
* @param Request $request incoming request whose query string is preserved
|
||||||
|
* @return RedirectResponse 302 redirect to /graph.svg
|
||||||
|
*/
|
||||||
|
#[Route('/', name: 'index', methods: ['GET'])]
|
||||||
|
public function index(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$query = $request->query->all();
|
||||||
|
$url = '/graph.svg' . ($query ? '?' . http_build_query($query) : '');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
private function fetchAllContributions(): array
|
return new Response(
|
||||||
{
|
json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||||
$contributions = [];
|
$statusCode,
|
||||||
|
['Content-Type' => 'application/json'],
|
||||||
if ($this->githubUser !== '' && $this->githubToken !== '') {
|
);
|
||||||
try {
|
|
||||||
$contributions = $this->merge($contributions, $this->github->fetch($this->githubUser, $this->githubToken));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->warning('GitHub fetch failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->gitlabUser !== '' && $this->gitlabToken !== '') {
|
|
||||||
try {
|
|
||||||
$contributions = $this->merge($contributions, $this->gitlab->fetch(
|
|
||||||
$this->gitlabUser,
|
|
||||||
$this->gitlabToken,
|
|
||||||
$this->gitlabUrl !== '' ? $this->gitlabUrl : 'https://gitlab.com'
|
|
||||||
));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->warning('GitLab fetch failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->giteaUser !== '' && $this->giteaToken !== '' && $this->giteaUrl !== '') {
|
|
||||||
try {
|
|
||||||
$contributions = $this->merge($contributions, $this->gitea->fetch(
|
|
||||||
$this->giteaUser,
|
|
||||||
$this->giteaToken,
|
|
||||||
rtrim($this->giteaUrl, '/')
|
|
||||||
));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->warning('Gitea fetch failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $contributions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sum contributions by date from two maps. */
|
|
||||||
private function merge(array $base, array $new): array
|
|
||||||
{
|
|
||||||
foreach ($new as $date => $count) {
|
|
||||||
$base[$date] = ($base[$date] ?? 0) + $count;
|
|
||||||
}
|
|
||||||
return $base;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the last 365 days of contributions from the GitHub GraphQL API.
|
|
||||||
*
|
|
||||||
* Required token scopes: read:user
|
|
||||||
*/
|
|
||||||
class GitHubProvider
|
|
||||||
{
|
|
||||||
private const GRAPHQL_URL = 'https://api.github.com/graphql';
|
|
||||||
|
|
||||||
private const QUERY = <<<'GRAPHQL'
|
|
||||||
query($username: String!, $from: DateTime!, $to: DateTime!) {
|
|
||||||
user(login: $username) {
|
|
||||||
contributionsCollection(from: $from, to: $to) {
|
|
||||||
contributionCalendar {
|
|
||||||
weeks {
|
|
||||||
contributionDays {
|
|
||||||
date
|
|
||||||
contributionCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GRAPHQL;
|
|
||||||
|
|
||||||
public function __construct(private readonly HttpClientInterface $client) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, int> date (Y-m-d) => contribution count
|
|
||||||
*/
|
|
||||||
public function fetch(string $username, string $token): array
|
|
||||||
{
|
|
||||||
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
|
|
||||||
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
|
|
||||||
|
|
||||||
$response = $this->client->request('POST', self::GRAPHQL_URL, [
|
|
||||||
'headers' => [
|
|
||||||
'Authorization' => "Bearer $token",
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
],
|
|
||||||
'json' => [
|
|
||||||
'query' => self::QUERY,
|
|
||||||
'variables' => ['username' => $username, 'from' => $from, 'to' => $to],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$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'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
public function __construct(private readonly HttpClientInterface $client) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, int> date (Y-m-d) => event count
|
|
||||||
*/
|
|
||||||
public function fetch(string $username, string $token, string $baseUrl = 'https://gitlab.com'): array
|
|
||||||
{
|
|
||||||
$baseUrl = rtrim($baseUrl, '/');
|
|
||||||
|
|
||||||
// Resolve numeric user ID from username
|
|
||||||
$userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [
|
|
||||||
'headers' => ['PRIVATE-TOKEN' => $token],
|
|
||||||
'query' => ['username' => $username],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$users = $userResponse->toArray();
|
|
||||||
if (empty($users)) {
|
|
||||||
throw new \RuntimeException("GitLab: user '$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' => $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);
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
public function __construct(private readonly HttpClientInterface $client) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, int> date (Y-m-d) => contribution count
|
|
||||||
*/
|
|
||||||
public function fetch(string $username, string $token, string $baseUrl): array
|
|
||||||
{
|
|
||||||
$baseUrl = rtrim($baseUrl, '/');
|
|
||||||
$response = $this->client->request('GET', "$baseUrl/api/v1/users/$username/heatmap", [
|
|
||||||
'headers' => ['Authorization' => "token $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'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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 (0–4) matched to GitHub's colour palette
|
* - 5 intensity levels (0–4) 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 = [
|
||||||
@@ -28,13 +30,18 @@ class SvgRenderer
|
|||||||
];
|
];
|
||||||
|
|
||||||
private const CELL = 10; // px
|
private const CELL = 10; // px
|
||||||
private const GAP = 3; // px
|
private const STEP = 13; // CELL + 3px gap
|
||||||
private const STEP = 13; // CELL + GAP
|
|
||||||
private const WEEKS = 53;
|
private const WEEKS = 53;
|
||||||
private const MARGIN_X = 28; // left margin for day labels
|
private const MARGIN_X = 28; // left margin for day labels
|
||||||
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'];
|
||||||
@@ -42,7 +49,7 @@ class SvgRenderer
|
|||||||
$today = new \DateTimeImmutable('today');
|
$today = new \DateTimeImmutable('today');
|
||||||
// align grid: last column always ends on the Saturday of the current week
|
// align grid: last column always ends on the Saturday of the current week
|
||||||
$endSat = $today->modify('Saturday this week');
|
$endSat = $today->modify('Saturday this week');
|
||||||
$start = $endSat->modify('-' . (self::WEEKS - 1) . ' weeks')->modify('Sunday');
|
$start = $endSat->modify('-' . (self::WEEKS - 1) . ' weeks')->modify('last Sunday');
|
||||||
|
|
||||||
$grid = $this->buildGrid($start, $today, $contributions);
|
$grid = $this->buildGrid($start, $today, $contributions);
|
||||||
$stats = $this->computeStats($contributions);
|
$stats = $this->computeStats($contributions);
|
||||||
@@ -69,7 +76,7 @@ class SvgRenderer
|
|||||||
$out .= $this->renderCells($grid, $colors);
|
$out .= $this->renderCells($grid, $colors);
|
||||||
|
|
||||||
// Legend row (Less → More)
|
// Legend row (Less → More)
|
||||||
$out .= $this->renderLegend($totalW, $totalH, $colors);
|
$out .= $this->renderLegend($totalH, $colors);
|
||||||
|
|
||||||
// Total count
|
// Total count
|
||||||
$out .= sprintf(
|
$out .= sprintf(
|
||||||
@@ -90,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
|
||||||
{
|
{
|
||||||
@@ -124,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)];
|
||||||
@@ -131,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 = '';
|
||||||
@@ -141,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);
|
||||||
|
|
||||||
@@ -182,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 = '';
|
||||||
@@ -196,9 +215,10 @@ 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' : '';
|
||||||
$label = $cell['count'] > 0
|
$label = $cell['count'] > 0
|
||||||
? $cell['count'] . ' contribution' . ($cell['count'] !== 1 ? 's' : '') . ' on ' . date('F j, Y', $ts)
|
? $cell['count'] . ' contribution' . $suffix . ' on ' . date('F j, Y', $ts)
|
||||||
: 'No contributions on ' . date('F j, Y', $ts);
|
: 'No contributions on ' . date('F j, Y', $ts);
|
||||||
|
|
||||||
$out .= sprintf(
|
$out .= sprintf(
|
||||||
@@ -216,7 +236,8 @@ class SvgRenderer
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderLegend(int $totalW, int $totalH, array $colors): string
|
/** @param array{bg: string, text: string, levels: array<int, string>} $colors */
|
||||||
|
private function renderLegend(int $totalH, array $colors): string
|
||||||
{
|
{
|
||||||
$y = $totalH - 14;
|
$y = $totalH - 14;
|
||||||
$out = sprintf(
|
$out = sprintf(
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user