Save to database #13
@@ -34,8 +34,12 @@ final class GraphController
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters:
|
||||
* theme string dark|light (default: dark)
|
||||
* Serves the contribution heatmap SVG, from a 1-hour cache on repeat requests.
|
||||
*
|
||||
* Rejects requests to disallowed hosts (ALLOWED_HOSTS env) with a 403.
|
||||
*
|
||||
* @param Request $request query parameter `theme` selects "dark" (default) or "light"
|
||||
* @return Response image/svg+xml body, cacheable for 3600s
|
||||
*/
|
||||
#[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])]
|
||||
public function graph(Request $request): Response
|
||||
@@ -66,6 +70,12 @@ final class GraphController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects "/" to "/graph.svg", forwarding any query string (e.g. ?theme=light).
|
||||
*
|
||||
* @param Request $request incoming request whose query string is preserved
|
||||
* @return RedirectResponse 302 redirect to /graph.svg
|
||||
*/
|
||||
#[Route('/', name: 'index', methods: ['GET'])]
|
||||
public function index(Request $request): RedirectResponse
|
||||
{
|
||||
@@ -75,6 +85,12 @@ final class GraphController
|
||||
return new RedirectResponse($url, 302);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports per-provider connectivity status as JSON.
|
||||
*
|
||||
* @return Response 200 with {"status":"ok",...} or 503 with {"status":"degraded",...}
|
||||
* when any configured provider's probe reports an error
|
||||
*/
|
||||
#[Route('/health', name: 'health', methods: ['GET'])]
|
||||
public function health(): Response
|
||||
{
|
||||
|
||||
@@ -4,8 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace GitContributionGraph\Entity;
|
||||
|
||||
/** A single provider's contribution count for one day, as stored in {@see \GitContributionGraph\Service\ContributionStore}. */
|
||||
final class Contribution
|
||||
{
|
||||
/**
|
||||
* @param string $provider provider identifier, e.g. "github"
|
||||
* @param int $date day of the contribution, as a unix timestamp
|
||||
* @param int $count contribution count for that day
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $provider,
|
||||
public readonly int $date,
|
||||
|
||||
@@ -15,6 +15,9 @@ class ContributionStore
|
||||
|
||||
/**
|
||||
* Opens (creating if needed) the SQLite store at $dbPath and ensures the schema exists.
|
||||
*
|
||||
* @param string $dbPath filesystem path to the SQLite database file
|
||||
* @param ?int $retentionDays days of history to keep; null or 0 keeps rows forever
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db",
|
||||
@@ -43,6 +46,10 @@ class ContributionStore
|
||||
|
||||
/**
|
||||
* Inserts a contribution count, overwriting any existing count for the same provider/date.
|
||||
*
|
||||
* @param string $provider provider identifier, e.g. "github"
|
||||
* @param int $unixtime day of the contribution, as a unix timestamp
|
||||
* @param int $count contribution count for that day
|
||||
*/
|
||||
public function add(string $provider, int $unixtime, int $count): void
|
||||
{
|
||||
@@ -56,6 +63,9 @@ class ContributionStore
|
||||
|
||||
/**
|
||||
* Deletes the contribution row for the given provider/date, if any.
|
||||
*
|
||||
* @param string $provider provider identifier, e.g. "github"
|
||||
* @param int $unixtime day to delete, as a unix timestamp
|
||||
*/
|
||||
public function remove(string $provider, int $unixtime): void
|
||||
{
|
||||
@@ -69,7 +79,8 @@ class ContributionStore
|
||||
/**
|
||||
* Upserts a batch of date => count pairs for a provider via repeated add() calls.
|
||||
*
|
||||
* @param array<int, int> $dateCounts unix timestamp => count
|
||||
* @param string $provider provider identifier, e.g. "github"
|
||||
* @param array<int, int> $dateCounts unix timestamp => count
|
||||
*/
|
||||
public function merge(string $provider, array $dateCounts): void
|
||||
{
|
||||
@@ -80,6 +91,9 @@ class ContributionStore
|
||||
|
||||
/**
|
||||
* Returns the most recent stored unix timestamp for a provider, or null if it has no rows.
|
||||
*
|
||||
* @param string $provider provider identifier, e.g. "github"
|
||||
* @return ?int unix timestamp of the latest stored day, or null if none stored yet
|
||||
*/
|
||||
public function latestDate(string $provider): ?int
|
||||
{
|
||||
@@ -92,6 +106,10 @@ class ContributionStore
|
||||
|
||||
/**
|
||||
* Returns all stored contributions for a provider, optionally limited to the last $sinceDays days.
|
||||
*
|
||||
* @param string $provider provider identifier, e.g. "github"
|
||||
* @param ?int $sinceDays if set, only rows from the last N days are returned
|
||||
* @return ContributionCollection
|
||||
*/
|
||||
public function all(string $provider, ?int $sinceDays = null): ContributionCollection
|
||||
{
|
||||
@@ -106,9 +124,12 @@ class ContributionStore
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
/** @var array<int, array{provider: string, date: int|string, count: int|string}> $rows */
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$contributions = array_map(
|
||||
static fn(array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||
$rows,
|
||||
);
|
||||
|
||||
return new ContributionCollection(...$contributions);
|
||||
|
||||
@@ -9,6 +9,10 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
trait ProbeTrait
|
||||
{
|
||||
/**
|
||||
* Reports NotConfigured if credentials are missing, otherwise pings the provider
|
||||
* and reports Ok or Error (with a classified error code and message).
|
||||
*/
|
||||
public function probe(): ProviderStatus
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
|
||||
@@ -8,13 +8,17 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
final class ProviderHealthChecker
|
||||
{
|
||||
/** @param iterable<ProviderInterface> $providers */
|
||||
public function __construct(
|
||||
#[AutowireIterator('app.provider')]
|
||||
private readonly iterable $providers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{status: string, providers: array<string, array<string, string>>}
|
||||
* Probes every configured provider and aggregates the results for the /health endpoint.
|
||||
*
|
||||
* @return array{status: string, providers: array<string, array<string, string>>} 'status' is
|
||||
* "degraded" if any provider's probe() reported an error, otherwise "ok"
|
||||
*/
|
||||
public function check(): array
|
||||
{
|
||||
|
||||
@@ -4,8 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace GitContributionGraph\Service\Provider;
|
||||
|
||||
/** Result of probing a single provider, as reported by the /health endpoint. */
|
||||
final class ProviderStatus
|
||||
{
|
||||
/**
|
||||
* @param string $name provider identifier, e.g. "github"
|
||||
* @param ProviderStatusType $status overall outcome of the probe
|
||||
* @param ?ProviderErrorCode $error classified error code, set only when $status is Error
|
||||
* @param ?string $message human-readable error detail, set only when $status is Error
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly ProviderStatusType $status,
|
||||
@@ -13,7 +20,11 @@ final class ProviderStatus
|
||||
public readonly ?string $message = null,
|
||||
) {}
|
||||
|
||||
/** @return array<string, string> */
|
||||
/**
|
||||
* Converts to the array shape used in the /health JSON response, omitting error/message when unset.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = ['status' => $this->status->value];
|
||||
|
||||
@@ -36,6 +36,12 @@ final class SvgRenderer
|
||||
private const MARGIN_Y = 20; // top margin for month labels
|
||||
private const PADDING = 10; // outer padding
|
||||
|
||||
/**
|
||||
* Builds the full self-contained SVG heatmap for a contribution map.
|
||||
*
|
||||
* @param array<string, int> $contributions date (Y-m-d) => contribution count
|
||||
* @param string $theme "dark" or "light"; unknown values fall back to "dark"
|
||||
*/
|
||||
public function render(array $contributions, string $theme = 'dark'): string
|
||||
{
|
||||
$colors = self::THEMES[$theme] ?? self::THEMES['dark'];
|
||||
@@ -91,6 +97,9 @@ final class SvgRenderer
|
||||
/**
|
||||
* Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int]
|
||||
* or null if the date is in the future.
|
||||
*
|
||||
* @param array<string, int> $contributions date (Y-m-d) => contribution count
|
||||
* @return array<int, array<int, array{date: string, count: int}|null>>
|
||||
*/
|
||||
private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array
|
||||
{
|
||||
@@ -125,6 +134,10 @@ final class SvgRenderer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $contributions date (Y-m-d) => contribution count
|
||||
* @return array{total: int}
|
||||
*/
|
||||
private function computeStats(array $contributions): array
|
||||
{
|
||||
return ['total' => array_sum($contributions)];
|
||||
@@ -132,6 +145,7 @@ final class SvgRenderer
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @param array<int, array<int, array{date: string, count: int}|null>> $grid */
|
||||
private function renderMonthLabels(array $grid, string $textColor): string
|
||||
{
|
||||
$out = '';
|
||||
@@ -183,6 +197,10 @@ final class SvgRenderer
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, array{date: string, count: int}|null>> $grid
|
||||
* @param array{bg: string, text: string, levels: array<int, string>} $colors
|
||||
*/
|
||||
private function renderCells(array $grid, array $colors): string
|
||||
{
|
||||
$out = '';
|
||||
@@ -218,6 +236,7 @@ final class SvgRenderer
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array{bg: string, text: string, levels: array<int, string>} $colors */
|
||||
private function renderLegend(int $totalH, array $colors): string
|
||||
{
|
||||
$y = $totalH - 14;
|
||||
|
||||
@@ -54,6 +54,7 @@ final class SvgRendererTest extends TestCase
|
||||
$this->assertStringContainsString($expectedColor, $svg);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function theme_background_provider(): iterable
|
||||
{
|
||||
yield 'dark theme' => ['dark', '#0d1117'];
|
||||
@@ -97,6 +98,7 @@ final class SvgRendererTest extends TestCase
|
||||
$this->assertStringContainsString('>' . $label . '<', $svg);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function day_of_week_label_provider(): iterable
|
||||
{
|
||||
yield 'Monday' => ['Mon'];
|
||||
|
||||
Reference in New Issue
Block a user