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
+23 -9
View File
@@ -51,31 +51,45 @@ final class GiteaProvider implements ProviderInterface
])->getContent();
}
public function startFetch(): ResponseInterface
/**
* 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]);
return $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [
$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];
}
/**
* @return array<string, int> date (Y-m-d) => contribution count
* 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
{
/** @var ResponseInterface $handle */
$data = $handle->toArray();
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp();
$result = [];
['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'];
}