feat(store): fetch contributions incrementally and persist history in SQLite

Bound each provider's startFetch()/resolveFetch() to an optional
since/until window, wire a SQLite-backed ContributionStore into
ContributionAggregator (fetch only the trailing window past the last
stored date, merge into the store, prune by CONTRIBUTIONS_RETENTION_DAYS),
and add a graph:contributions:refetch command to force a full or ranged
re-fetch in <=365-day chunks. This is the root fix for the full
365-day-refetch timeout that used to hit on every cache miss.
This commit is contained in:
2026-07-12 23:42:41 +02:00
parent 41e88144a4
commit 4ff45b7c46
16 changed files with 642 additions and 96 deletions
@@ -29,6 +29,7 @@ final class GitHubProviderTest extends TestCase
);
}
/** @param array<int, array{contributionDays: array<int, array{date: string, contributionCount: int}>}> $weeks */
private function stubGraphqlResponse(array $weeks): ResponseInterface
{
$response = $this->createStub(ResponseInterface::class);
@@ -131,4 +132,27 @@ final class GitHubProviderTest extends TestCase
$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);
}
}
@@ -31,6 +31,7 @@ final class GitLabProviderTest extends TestCase
);
}
/** @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);
@@ -128,4 +129,27 @@ final class GitLabProviderTest extends TestCase
$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']);
}
}
@@ -30,6 +30,7 @@ final class GiteaProviderTest extends TestCase
);
}
/** @param array<int, array{timestamp: int, contributions: int}> $data */
private function stubResponse(array $data): ResponseInterface
{
$response = $this->createStub(ResponseInterface::class);
@@ -111,4 +112,44 @@ final class GiteaProviderTest extends TestCase
$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);
}
}
@@ -31,7 +31,7 @@ final class ProbeTraitTest extends TestCase
public function isConfigured(): bool { return $this->configured; }
public function getName(): string { return 'test'; }
public function ping(): void { ($this->ping)(); }
public function startFetch(): mixed { return null; }
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed { return null; }
public function resolveFetch(mixed $handle): array { return []; }
};
}