Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76ed6efc8 | ||
|
|
bce564d68b | ||
|
|
b3dc7a2298 | ||
|
|
9087f91855 | ||
|
|
885dec357e | ||
|
|
78e7a72ee9 |
+1
-1
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user