feat(git): Commit-Heatmap ueber ein Jahr

Ein Feld je Tag auf der Projektseite, Wochen als Spalten, mit Farbskala und
Datum im Tooltip.

Woher die Zahlen kommen, unterscheidet sich je Anbieter. GitHub fuehrt die
Statistik selbst und liefert 52 Wochen mit Tageswerten in einem einzigen
Aufruf. GitLab und Gitea kennen nichts Vergleichbares; dort werden Commits
seitenweise geholt, gedeckelt auf fuenf Seiten - genug fuer die allermeisten
Repositories und wenig genug, um kein fremdes Kontingent zu sprengen.

Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen. Reichen
die Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an und sagt
es dazu. Ein leeres Feld wuerde sonst behaupten, an dem Tag sei nicht
gearbeitet worden, obwohl darueber nichts bekannt ist - genau diesen Fehler
hatte die Verlaufslinie schon einmal.

Geholt wird hoechstens einmal am Tag und getrennt vom uebrigen Abgleich: an
einem Jahresueberblick aendert sich von Stunde zu Stunde nichts Sichtbares,
der Abgleich laeuft aber im Zweifel stuendlich.

Der Kalender benutzt getJson statt getJsonSafe. Waere ein Rate-Limit als
"keine Daten" durchgegangen, haette der gesetzte Zeitstempel die Heatmap
einen ganzen Tag lang unterdrueckt. Jetzt bleibt er bei einem Fehlschlag
ungesetzt, und der naechste Abgleich versucht es erneut - das deckt auch den
Fall ab, dass GitHub die Statistik gerade erst berechnet und mit 202
antwortet.

Nachgewiesen an vercel/swr: 49 Commits an 33 Tagen ueber ein volles Jahr aus
der Statistik, und mit kurzer Historie 90 statt 365 Felder samt Hinweis auf
den Deckel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 15:02:19 +02:00
co-authored by Claude Opus 5
parent fce0a90cc3
commit ca6181b58f
12 changed files with 666 additions and 4 deletions
+55
View File
@@ -2,6 +2,7 @@ import type { Repository } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories";
import type { RepoSnapshotData } from "@/lib/git/types";
@@ -57,6 +58,9 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
try {
const data = await client.fetchRepo(ref);
await persistSnapshot(repository, data);
// Erst nach dem Snapshot: die Zeile muss existieren, bevor der Kalender
// hineingeschrieben werden kann.
await maybeRefreshCalendar(repository.id, client, ref);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.id);
return {
@@ -141,6 +145,57 @@ export async function syncProject(projectId: string): Promise<ProjectSyncResult>
};
}
/** Wie lange ein geholter Kalender genuegt. */
const CALENDAR_TTL_MS = 20 * 60 * 60 * 1000;
/**
* Holt die Jahresuebersicht fuer die Heatmap – hoechstens einmal am Tag.
*
* Sie kostet zusaetzliche Aufrufe, bei GitLab und Gitea sogar mehrere. An
* einem Jahresueberblick aendert sich von Stunde zu Stunde aber nichts
* Sichtbares, und der Abgleich laeuft im Zweifel stuendlich.
*
* Fehler bleiben hier: der Kalender ist Beiwerk. Wenn er nicht zu holen ist,
* soll deshalb nicht der ganze Abgleich als gescheitert gelten.
*/
async function maybeRefreshCalendar(
repositoryId: string,
client: GitClient,
ref: { owner: string; name: string },
): Promise<void> {
try {
const snapshot = await prisma.repoSnapshot.findUnique({
where: { repositoryId },
select: { commitCalendarAt: true },
});
const zuletzt = snapshot?.commitCalendarAt?.getTime() ?? 0;
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
const calendar = await client.fetchCommitCalendar(ref);
// Nichts erhalten: entweder rechnet GitHub die Statistik gerade erst,
// oder das Repository hat keine Commits. Der Zeitstempel bleibt dann
// ungesetzt, damit es der naechste Abgleich erneut versucht – ihn hier zu
// setzen wuerde die Heatmap fuer einen Tag unterdruecken, obwohl sie
// vielleicht schon in Minuten bereitsteht.
if (!calendar) return;
await prisma.repoSnapshot.update({
where: { repositoryId },
data: {
commitCalendar: calendar as unknown as object,
commitCalendarAt: new Date(),
},
});
} catch (error) {
// Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und
// soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es
// der naechste Durchgang von selbst wieder.
console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error);
}
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef(
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,