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
+112 -29
View File
@@ -5,6 +5,7 @@ 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;
@@ -15,16 +16,31 @@ use Psr\Log\LoggerInterface;
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->logger);
$aggregator = new ContributionAggregator([], $this->store, $this->logger);
$result = $aggregator->aggregate();
@@ -34,10 +50,9 @@ final class ContributionAggregatorTest extends TestCase
#[Test]
public function it_skips_unconfigured_providers(): void
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(false);
$provider = $this->makeProvider('github', configured: false);
$aggregator = new ContributionAggregator([$provider], $this->logger);
$aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate();
@@ -47,69 +62,68 @@ final class ContributionAggregatorTest extends TestCase
#[Test]
public function it_returns_contributions_from_a_configured_provider(): void
{
$provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(true);
$provider->method('resolveFetch')->willReturn(['2024-01-01' => 3]);
$date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$provider = $this->makeProvider('github', fresh: [$date => 3]);
$aggregator = new ContributionAggregator([$provider], $this->logger);
$aggregator = new ContributionAggregator([$provider], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 3], $result);
$this->assertSame([$date => 3], $result);
}
#[Test]
public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void
{
$providerA = $this->createStub(ProviderInterface::class);
$providerA->method('isConfigured')->willReturn(true);
$providerA->method('resolveFetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]);
$dateA = (new \DateTimeImmutable('-1 day'))->format('Y-m-d');
$dateB = (new \DateTimeImmutable('-2 days'))->format('Y-m-d');
$providerB = $this->createStub(ProviderInterface::class);
$providerB->method('isConfigured')->willReturn(true);
$providerB->method('resolveFetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]);
$providerA = $this->makeProvider('github', fresh: [$dateA => 3, $dateB => 1]);
$providerB = $this->makeProvider('gitlab', fresh: [$dateA => 2]);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 5, '2024-01-02' => 1, '2024-01-03' => 5], $result);
$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->createStub(ProviderInterface::class);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$healthy = $this->makeProvider('github', fresh: [$date => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result);
$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->createStub(ProviderInterface::class);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$healthy = $this->makeProvider('github', fresh: [$date => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger);
$result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result);
$this->assertSame([$date => 7], $result);
}
#[Test]
@@ -119,10 +133,11 @@ final class ContributionAggregatorTest extends TestCase
$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], $logger))->aggregate();
(new ContributionAggregator([$provider], $this->store, $logger))->aggregate();
}
#[Test]
@@ -132,9 +147,77 @@ final class ContributionAggregatorTest extends TestCase
$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], $logger))->aggregate();
(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);
}
}
@@ -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 []; }
};
}