6 Commits
Author SHA1 Message Date
haylan a76ed6efc8 chore(todo): mark completed tasks and expand step 3 detail 2026-07-11 16:46:50 +02:00
haylan bce564d68b docs(readme): fix phpunit command paths
There is no bin/phpunit (only bin/console) — the binary lives at
vendor/bin/phpunit, matching CLAUDE.md's documented commands.
2026-07-11 16:46:47 +02:00
haylan b3dc7a2298 fix(docker): target the dev stage when building for local development
docker-compose.override.yml only overrode the dockerfile, so it kept
inheriting target: final from docker-compose.yml — but Dockerfile.dev
is single-stage and has no final stage, so the dev build failed. Name
the stage in Dockerfile.dev and target it explicitly from the
override.
2026-07-11 16:46:29 +02:00
haylan 9087f91855 feat(store): finish ContributionStore and add typed entities
- Fix add()'s upsert SQL (was invalid SQL from typos) and use it for
  the new merge() as well.
- Fix remove()'s missing FROM keyword and drop the unindexed LIKE scan
  in favor of an exact match.
- Add latestDate(), all() with optional sinceDays filtering, and
  prune() with a retention-day cutoff.
- Add Contribution (immutable value object) and ContributionCollection
  (IteratorAggregate + Countable) so all() returns something typed
  instead of a raw date => count array.
- Cover all of the above with unit tests against an in-memory SQLite
  database.
2026-07-11 16:46:21 +02:00
haylan 885dec357e refactor(github): build GraphQL query without the client bundle
The eightpoints/guzzle-bundle and idci/graphql-client-bundle only
existed to build one near-static query string, and the bundle's own
HTTP transport was already bypassed in favor of Symfony HttpClient.
Build the query with sprintf/json_encode instead, drop both bundles
from config/bundles.php, and update the test double accordingly.
2026-07-11 16:46:11 +02:00
haylan 78e7a72ee9 fix(provider): normalize baseUrl before assigning readonly property
Promoted readonly properties can't be reassigned in the constructor
body, so GitLabProvider and GiteaProvider threw on every instantiation.
Normalize the plain parameter first, then assign to a separately
declared readonly property.
2026-07-11 16:46:04 +02:00
16 changed files with 384 additions and 146 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM dunglas/frankenphp:1-php8.4-alpine FROM dunglas/frankenphp:1-php8.4-alpine AS dev
RUN apk add --no-cache icu-dev libzip-dev \ RUN apk add --no-cache icu-dev libzip-dev \
&& docker-php-ext-install -j$(nproc) intl opcache zip \ && docker-php-ext-install -j$(nproc) intl opcache zip \
+5 -5
View File
@@ -175,7 +175,7 @@ docker compose up -d --build
docker compose exec graph sh docker compose exec graph sh
# Run tests inside the container # Run tests inside the container
docker compose exec graph php bin/phpunit docker compose exec graph vendor/bin/phpunit
# Disable Xdebug for faster test runs # Disable Xdebug for faster test runs
XDEBUG_MODE=off docker compose up -d XDEBUG_MODE=off docker compose up -d
@@ -195,16 +195,16 @@ docker compose -f docker-compose.yml up -d --build
```bash ```bash
# Run full suite # Run full suite
php bin/phpunit vendor/bin/phpunit
# Human-readable output # Human-readable output
php bin/phpunit --testdox vendor/bin/phpunit --testdox
# Single file # Single file
php bin/phpunit tests/Unit/Service/SvgRendererTest.php vendor/bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Filter by name # Filter by name
php bin/phpunit --filter it_renders vendor/bin/phpunit --filter it_renders
``` ```
--- ---
+64 -16
View File
@@ -10,7 +10,7 @@ Order matters — later steps assume earlier ones are done.
They exist only to build one near-static GitHub GraphQL query string They exist only to build one near-static GitHub GraphQL query string
(`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already (`GitHubProvider.php:59-74`); the bundle's own HTTP transport is already
bypassed (comment at `GitHubProvider.php:76-77`). bypassed (comment at `GitHubProvider.php:76-77`).
- [ ] Replace - [x] Replace
`$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain `$graphqlClient->buildQuery(...)->getGraphQLQuery()` with a plain
`sprintf`/heredoc query string. Delete the two packages from `sprintf`/heredoc query string. Delete the two packages from
`composer.json`, `config/bundles.php`, and delete `composer.json`, `config/bundles.php`, and delete
@@ -26,7 +26,7 @@ Order matters — later steps assume earlier ones are done.
## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4) ## 2. Fix stale `config/services.yaml` (found during exploration, blocks step 1 & 4)
- [ ] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the - [x] `services.yaml` still binds `$username`/`$token`/`$baseUrl` to the
pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and pre-refactor FQCNs (`App\Service\GitHubProvider` etc.) and
`_instanceof: App\Service\ProviderInterface`, left over from the `_instanceof: App\Service\ProviderInterface`, left over from the
`Service/``Provider/`+`Renderer/` namespace reorg. Update all of `Service/``Provider/`+`Renderer/` namespace reorg. Update all of
@@ -37,31 +37,78 @@ Order matters — later steps assume earlier ones are done.
## 3. `ContributionStore` (SQLite via native PDO) ## 3. `ContributionStore` (SQLite via native PDO)
> Docs: [PDO](https://www.php.net/manual/en/book.pdo.php) ·
> [PDO_SQLITE driver](https://www.php.net/manual/en/ref.pdo-sqlite.php) ·
> [PDO::prepare / prepared statements](https://www.php.net/manual/en/pdo.prepare.php) ·
> [SQLite `INSERT ... ON CONFLICT` upsert](https://www.sqlite.org/lang_upsert.html) ·
> [SQLite `STRICT` tables](https://www.sqlite.org/stricttables.html) ·
> [SQLite `WITHOUT ROWID` tables](https://www.sqlite.org/withoutrowid.html) ·
> [SQLite datatypes (why `date` is `INTEGER`/unixtime, not `TEXT`)](https://www.sqlite.org/datatype3.html)
- [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at - [x] New `src/Service/ContributionStore.php`. PDO SQLite, DB file at
`%kernel.project_dir%/var/data/contributions.db` (configurable `%kernel.project_dir%/var/data/contributions.db` (configurable
constructor arg), table created lazily: constructor arg), table created lazily. **Schema note:** `date` is
stored as a Unix timestamp (`INTEGER`), not `TEXT` — matches the
current implementation:
```sql ```sql
CREATE TABLE IF NOT EXISTS contributions ( CREATE TABLE IF NOT EXISTS contributions (
provider TEXT NOT NULL, provider TEXT NOT NULL,
date TEXT NOT NULL, date INTEGER NOT NULL,
count INTEGER NOT NULL CHECK (count >= 0), count INTEGER NOT NULL CHECK (count >= 0),
PRIMARY KEY (provider, date) PRIMARY KEY (provider, date)
) WITHOUT ROWID, STRICT; ) WITHOUT ROWID, STRICT;
``` ```
- [ ] `latestDate(string $provider): ?string` — `SELECT MAX(date) WHERE provider = ?`. - [x] `add(string $provider, int $unixtime, int $count): void` — plain
- [ ] `merge(string $provider, array $dateCounts): void` — upsert via insert (done, needs test coverage — see below).
`INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`. - [x] Fix `add()`: currently a plain `INSERT`, so re-adding an existing
- [ ] `all(string $provider, ?int $sinceDays = null): array` — `date => count`; `(provider, date)` throws a unique-constraint violation instead of
`null` returns full history (no arbitrary cutoff), a value filters to the upserting. Switch to
last N days. `INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`
- [ ] `prune(): void` — `DELETE FROM contributions WHERE date < ?` using (see SQLite upsert doc above), or keep `add()` insert-only and add a
the configured retention window; no-ops if retention is unset/0. separate `merge()` for the upsert case used by step 5.
- [ ] Constructor takes `?int $retentionDays` bound from new env var - [x] `remove(string $provider, int $unixtime): void` — started, has a
`CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). bug: `DELETE contributions WHERE ...` is invalid SQL, missing the
- [ ] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store & `FROM` keyword (must be `DELETE FROM contributions WHERE ...`); also
drop the `LIKE` on `provider` (exact match, use `=`) since it's an
unindexed wildcard scan for what should be an exact key lookup.
- [x] **`Contribution` entity.** Small immutable value object
(`src/Entity/Contribution.php`) wrapping one row: `provider` (string),
`date` (unix timestamp int, or `\DateTimeImmutable` — pick one and
use it consistently everywhere, including `add()`/`all()`), `count`
(int). Gives `ContributionStore::all()` and the aggregator something
typed to pass around instead of raw arrays/tuples.
- [x] **`ContributionCollection`.** Typed collection
(`src/Entity/ContributionCollection.php`) wrapping
`array<Contribution>` — implement `IteratorAggregate` + `Countable`
at minimum ([`IteratorAggregate`](https://www.php.net/manual/en/class.iteratoraggregate.php),
[`Countable`](https://www.php.net/manual/en/class.countable.php)) so
it can be `foreach`'d and `count()`'d like a normal array. This is
what `ContributionStore::all()` should return instead of a bare
`date => count` array.
- [x] `latestDate(string $provider): ?int` — `SELECT MAX(date) WHERE provider = ?`
(returns a unix timestamp, not a string, per the schema above).
- [x] `merge(string $provider, array $dateCounts): void` — upsert via
`INSERT ... ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count`;
`$dateCounts` keyed by unix timestamp.
- [x] `all(string $provider, ?int $sinceDays = null): ContributionCollection` —
fetch all rows for a provider, or since a given range. `null` returns
full history (no arbitrary cutoff), a value filters to rows where
`date >= (now - sinceDays * 86400)`. **Bug:** current stub in
`ContributionStore.php:58` has invalid PHP (`$provider == null` instead
of `?string $provider = null` — this won't parse) and returns `void`
instead of `ContributionCollection`; fix the signature when implementing.
- [x] `prune(): void``DELETE FROM contributions WHERE date < ?` using
the configured retention window (a unix timestamp cutoff); no-ops if
retention is unset/0.
- [x] Constructor takes `?int $retentionDays` bound from new env var
`CONTRIBUTIONS_RETENTION_DAYS` (empty/default = keep forever). *(constructor
param wired; the env binding itself is step 6's job.)*
- [x] Tests: `tests/Unit/Service/ContributionStoreTest.php` — store &
retrieve, upsert overwrites existing date, `sinceDays` filtering, retrieve, upsert overwrites existing date, `sinceDays` filtering,
`prune()` no-ops when retention unset, `prune()` deletes rows older `prune()` no-ops when retention unset, `prune()` deletes rows older
than the window when set. than the window when set.
- [x] Tests: `tests/Unit/Entity/ContributionTest.php` and
`ContributionCollectionTest.php` — construction, iteration, `count()`.
## 4. Wire `$since` through providers ## 4. Wire `$since` through providers
@@ -84,7 +131,8 @@ Order matters — later steps assume earlier ones are done.
overlap for late corrections), else `null` (first run). overlap for late corrections), else `null` (first run).
- [ ] `$fresh = $provider->fetch($since)`, `$store->merge($name, $fresh)`, - [ ] `$fresh = $provider->fetch($since)`, `$store->merge($name, $fresh)`,
then read back `$store->all($name, sinceDays: 371)` for the render then read back `$store->all($name, sinceDays: 371)` for the render
window (53 weeks × 7 days) and merge into the returned array. window (53 weeks × 7 days) — a `ContributionCollection` — and merge
its contributions into the returned array.
- [ ] Call `$store->prune()` once per `aggregate()` call, after all - [ ] Call `$store->prune()` once per `aggregate()` call, after all
providers have merged. providers have merged.
- [ ] Keep the existing try/catch-and-log-per-provider behavior — a - [ ] Keep the existing try/catch-and-log-per-provider behavior — a
-2
View File
@@ -3,6 +3,4 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true],
IDCI\Bundle\GraphQLClientBundle\IDCIGraphQLClientBundle::class => ['all' => true],
]; ];
+2 -59
View File
@@ -37,7 +37,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed> * @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key * @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator
* @psalm-type DeprecationType = array{package: string, version: string, message?: string} * @psalm-type DeprecationType = array{package: string, version: string, message?: string}
* @psalm-type DefaultsType = array{ * @psalm-type DefaultsType = array{
* public?: bool, * public?: bool,
@@ -302,7 +302,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* }, * },
* translator?: bool|array{ // Translator configuration * translator?: bool|array{ // Translator configuration
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: false
* fallbacks?: string|list<scalar|Param|null>, * fallbacks?: string|list<scalar|Param|null>,
* logging?: bool|Param, // Default: false * logging?: bool|Param, // Default: false
* formatter?: scalar|Param|null, // Default: "translator.formatter.default" * formatter?: scalar|Param|null, // Default: "translator.formatter.default"
@@ -833,73 +833,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* }>, * }>,
* } * }
* @psalm-type EightPointsGuzzleConfig = array{
* clients?: array<string, array{ // Default: []
* class?: scalar|Param|null, // Default: "%eight_points_guzzle.http_client.class%"
* base_url?: scalar|Param|null, // Default: null
* lazy?: bool|Param, // Default: false
* logging?: int|Param, // Default: null
* handler?: scalar|Param|null, // Default: null
* options?: array{
* headers?: array<string, scalar|Param|null>,
* allow_redirects?: mixed,
* auth?: mixed,
* query?: mixed,
* curl?: list<scalar|Param|null>,
* cert?: mixed,
* connect_timeout?: scalar|Param|null,
* debug?: bool|Param,
* decode_content?: mixed,
* delay?: float|Param,
* form_params?: array<string, mixed>,
* multipart?: list<mixed>,
* sink?: scalar|Param|null,
* http_errors?: bool|Param,
* expect?: mixed,
* ssl_key?: mixed,
* force_ip_resolve?: mixed, // Default: null
* stream?: bool|Param,
* synchronous?: bool|Param,
* read_timeout?: scalar|Param|null,
* timeout?: scalar|Param|null,
* verify?: mixed,
* cookies?: bool|Param,
* proxy?: string|array{
* http?: scalar|Param|null,
* https?: scalar|Param|null,
* no?: list<scalar|Param|null>,
* },
* version?: scalar|Param|null,
* },
* plugin?: array<mixed>,
* }>,
* logging?: bool|Param, // Default: true
* profiling?: bool|Param, // Default: true
* slow_response_time?: int|Param, // Default: 0
* }
* @psalm-type IdciGraphqlClientConfig = array{
* cache_enabled?: bool|Param, // Default: false
* clients?: list<array{ // Default: []
* http_client?: scalar|Param|null,
* cache?: scalar|Param|null,
* }>,
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -907,8 +852,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* eight_points_guzzle?: EightPointsGuzzleConfig,
* idci_graphql_client?: IdciGraphqlClientConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
+1
View File
@@ -4,6 +4,7 @@ services:
graph: graph:
build: build:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
target: dev
volumes: volumes:
- .:/app - .:/app
- ./vendor:/app/vendor - ./vendor:/app/vendor
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Entity;
final class Contribution
{
public function __construct(
public readonly string $provider,
public readonly int $date,
public readonly int $count,
) {
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Entity;
/**
* @implements \IteratorAggregate<int, Contribution>
*/
final class ContributionCollection implements \IteratorAggregate, \Countable
{
/** @var array<int, Contribution> */
private readonly array $contributions;
public function __construct(Contribution ...$contributions)
{
$this->contributions = $contributions;
}
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->contributions);
}
public function count(): int
{
return count($this->contributions);
}
}
+68 -16
View File
@@ -4,24 +4,30 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Contribution;
use App\Entity\ContributionCollection;
use PDO; use PDO;
class ContributionStore { class ContributionStore
{
private PDO $pdo; private PDO $pdo;
private const int DAY_IN_SECONDS = 86400;
public function __construct( public function __construct(
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db", private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db",
private readonly ?int $retentionDays = null, private readonly ?int $retentionDays = null,
) { ) {
if(!is_dir(\dirname($this->dbPath))){ $dir = \dirname($this->dbPath);
mkdir(\dirname($this->dbPath), 0755, recursive: true); if (!is_dir($dir)) {
mkdir($dir, 0755, recursive: true);
} }
$this->pdo = new PDO('sqlite:' . $this->dbPath); $this->pdo = new PDO('sqlite:' . $this->dbPath);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->prepareSchema(); $this->prepareSchema();
} }
private function prepareSchema():void{ private function prepareSchema(): void
{
$this->pdo->exec(' $this->pdo->exec('
CREATE TABLE IF NOT EXISTS contributions ( CREATE TABLE IF NOT EXISTS contributions (
provider TEXT NOT NULL, provider TEXT NOT NULL,
@@ -32,28 +38,74 @@ class ContributionStore {
'); ');
} }
public function add(string $provider, int $unixtime, int $count):void{ public function add(string $provider, int $unixtime, int $count): void
{
$stmt = $this->pdo->prepare(' $stmt = $this->pdo->prepare('
INSERT INTO contributions ( INSERT INTO contributions (provider, date, count)
provider, date, count VALUES (?, ?, ?)
) ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count
VALUES(
?,?,?
)
'); ');
$stmt->execute([$provider, $unixtime, $count]); $stmt->execute([$provider, $unixtime, $count]);
} }
public function remove(string $provider, int $unixtime):void{ public function remove(string $provider, int $unixtime): void
{
$stmt = $this->pdo->prepare(' $stmt = $this->pdo->prepare('
DELETE contributions DELETE FROM contributions
WHERE provider LIKE ? and date = ? WHERE provider = ? AND date = ?
'); ');
$stmt->execute([$provider, $unixtime]); $stmt->execute([$provider, $unixtime]);
} }
public function all():void{ /**
* @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);
}
} }
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;
}
public function all(string $provider, ?int $sinceDays = null): ContributionCollection
{
$sql = 'SELECT provider, date, count FROM contributions WHERE provider = ?';
$params = [$provider];
if ($sinceDays !== null) {
$sql .= ' AND date >= ?';
$params[] = time() - $sinceDays * self::DAY_IN_SECONDS;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$contributions = array_map(
static fn (array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']),
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
return new ContributionCollection(...$contributions);
}
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]);
}
} }
+7 -21
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Service\Provider; namespace App\Service\Provider;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -24,7 +22,6 @@ final class GitHubProvider implements ProviderInterface
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly GraphQLApiClientRegistryInterface $registry,
private readonly string $username, private readonly string $username,
private readonly string $token, private readonly string $token,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
@@ -54,25 +51,14 @@ final class GitHubProvider implements ProviderInterface
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); $from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); $to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
/** @var GraphQLApiClient $graphqlClient */ $query = sprintf(
$graphqlClient = $this->registry->get('github'); 'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }',
json_encode($this->username),
json_encode($from),
json_encode($to),
);
$query = $graphqlClient->buildQuery( // GitHub's GraphQL API requires application/json.
['user' => ['login' => $this->username]],
[
'contributionsCollection' => [
'_parameters' => ['from' => $from, 'to' => $to],
'contributionCalendar' => [
'weeks' => [
'contributionDays' => ['date', 'contributionCount'],
],
],
],
]
)->getGraphQLQuery();
// GitHub's GraphQL API requires application/json — the bundle's built-in
// transport sends form_params, so we use Symfony HttpClient here instead.
// request() doesn't block; the response is read in resolveFetch() so // request() doesn't block; the response is read in resolveFetch() so
// multiple providers' requests can be in flight at once. // multiple providers' requests can be in flight at once.
return $this->client->request('POST', self::GRAPHQL_URL, [ return $this->client->request('POST', self::GRAPHQL_URL, [
+4 -2
View File
@@ -19,14 +19,16 @@ final class GitLabProvider implements ProviderInterface
{ {
use ProbeTrait; use ProbeTrait;
private readonly string $baseUrl;
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly string $username, private readonly string $username,
private readonly string $token, private readonly string $token,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly string $baseUrl = '', string $baseUrl = '',
) { ) {
$this->baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); $this->baseUrl = rtrim($baseUrl !== '' ? $baseUrl : 'https://gitlab.com', '/');
} }
public function getName(): string public function getName(): string
+4 -2
View File
@@ -20,14 +20,16 @@ final class GiteaProvider implements ProviderInterface
{ {
use ProbeTrait; use ProbeTrait;
private readonly string $baseUrl;
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly string $username, private readonly string $username,
private readonly string $token, private readonly string $token,
private readonly string $baseUrl, string $baseUrl,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
$this->baseUrl = rtrim($this->baseUrl, '/'); $this->baseUrl = rtrim($baseUrl, '/');
} }
public function getName(): string public function getName(): string
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\Contribution;
use App\Entity\ContributionCollection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContributionCollectionTest extends TestCase
{
#[Test]
public function it_counts_zero_when_empty(): void
{
$collection = new ContributionCollection();
$this->assertCount(0, $collection);
}
#[Test]
public function it_counts_the_contributions_it_wraps(): void
{
$collection = new ContributionCollection(
new Contribution('github', 1_700_000_000, 1),
new Contribution('github', 1_700_086_400, 2),
);
$this->assertCount(2, $collection);
}
#[Test]
public function it_is_iterable_over_its_contributions(): void
{
$first = new Contribution('github', 1_700_000_000, 1);
$second = new Contribution('gitlab', 1_700_086_400, 2);
$collection = new ContributionCollection($first, $second);
$this->assertSame([$first, $second], iterator_to_array($collection));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\Contribution;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContributionTest extends TestCase
{
#[Test]
public function it_exposes_provider_date_and_count(): void
{
$contribution = new Contribution('github', 1_700_000_000, 4);
$this->assertSame('github', $contribution->provider);
$this->assertSame(1_700_000_000, $contribution->date);
$this->assertSame(4, $contribution->count);
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\ContributionStore;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContributionStoreTest extends TestCase
{
#[Test]
public function it_stores_and_retrieves_a_contribution(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 4);
$all = $store->all('github');
$this->assertCount(1, $all);
$this->assertSame(4, iterator_to_array($all)[0]->count);
}
#[Test]
public function it_upserts_on_a_repeated_date(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 4);
$store->add('github', 1_700_000_000, 9);
$all = $store->all('github');
$this->assertCount(1, $all);
$this->assertSame(9, iterator_to_array($all)[0]->count);
}
#[Test]
public function it_removes_a_contribution(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 4);
$store->remove('github', 1_700_000_000);
$this->assertCount(0, $store->all('github'));
}
#[Test]
public function it_merges_a_batch_of_date_counts(): void
{
$store = new ContributionStore(':memory:');
$store->merge('github', [1_700_000_000 => 1, 1_700_086_400 => 2]);
$this->assertCount(2, $store->all('github'));
}
#[Test]
public function it_reports_the_latest_date_for_a_provider(): void
{
$store = new ContributionStore(':memory:');
$store->add('github', 1_700_000_000, 1);
$store->add('github', 1_700_086_400, 2);
$this->assertSame(1_700_086_400, $store->latestDate('github'));
}
#[Test]
public function it_returns_null_latest_date_when_provider_has_no_rows(): void
{
$store = new ContributionStore(':memory:');
$this->assertNull($store->latestDate('github'));
}
#[Test]
public function it_filters_all_by_since_days(): void
{
$store = new ContributionStore(':memory:');
$now = time();
$store->add('github', $now - 10 * 86400, 1);
$store->add('github', $now - 400 * 86400, 2);
$this->assertCount(1, $store->all('github', sinceDays: 30));
}
#[Test]
public function it_does_not_prune_when_retention_is_unset(): void
{
$store = new ContributionStore(':memory:', retentionDays: null);
$store->add('github', time() - 1_000 * 86400, 1);
$store->prune();
$this->assertCount(1, $store->all('github'));
}
#[Test]
public function it_prunes_rows_older_than_the_retention_window(): void
{
$store = new ContributionStore(':memory:', retentionDays: 30);
$now = time();
$store->add('github', $now - 10 * 86400, 1);
$store->add('github', $now - 40 * 86400, 2);
$store->prune();
$this->assertCount(1, $store->all('github'));
}
}
@@ -5,9 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service\Provider; namespace App\Tests\Unit\Service\Provider;
use App\Service\Provider\GitHubProvider; use App\Service\Provider\GitHubProvider;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient;
use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use IDCI\Bundle\GraphQLClientBundle\Query\GraphQLQuery;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -23,22 +20,9 @@ final class GitHubProviderTest extends TestCase
string $username = 'user', string $username = 'user',
string $token = 'token', string $token = 'token',
?HttpClientInterface $client = null, ?HttpClientInterface $client = null,
?GraphQLApiClientRegistryInterface $registry = null,
): GitHubProvider { ): GitHubProvider {
if ($registry === null) {
$graphqlQuery = $this->createStub(GraphQLQuery::class);
$graphqlQuery->method('getGraphQLQuery')->willReturn('query {}');
$graphqlClient = $this->createStub(GraphQLApiClient::class);
$graphqlClient->method('buildQuery')->willReturn($graphqlQuery);
$registry = $this->createStub(GraphQLApiClientRegistryInterface::class);
$registry->method('get')->willReturn($graphqlClient);
}
return new GitHubProvider( return new GitHubProvider(
$client ?? $this->createStub(HttpClientInterface::class), $client ?? $this->createStub(HttpClientInterface::class),
$registry,
$username, $username,
$token, $token,
$this->createStub(LoggerInterface::class), $this->createStub(LoggerInterface::class),