Files
nebula/src/lib/sync/index.ts
T
florian.wildeandClaude Opus 5 ca6181b58f 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>
2026-07-26 15:02:19 +02:00

324 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
export interface SyncResult {
ok: boolean;
message: string;
fetchedAt?: string;
}
/**
* Holt den aktuellen Stand eines Repositories und schreibt ihn als Snapshot
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
*/
export async function syncRepository(repositoryId: string): Promise<SyncResult> {
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: { connection: true },
});
if (!repository) {
return { ok: false, message: "Repository nicht gefunden." };
}
const ref = resolveRepoRef(repository);
if (!ref) {
return {
ok: false,
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
};
}
let client;
if (repository.connection) {
client = clientForConnection(repository.connection);
} else {
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
const parsed = repository.repoUrl ? parseRepoUrl(repository.repoUrl) : null;
if (!parsed) {
return {
ok: false,
message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
};
}
client = createClient({
provider: parsed.guessedProvider,
baseUrl: parsed.baseUrl,
token: null,
});
}
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 {
ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
fetchedAt: new Date().toISOString(),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await prisma.repository.update({
where: { id: repository.id },
data: {
syncError: message,
lastSyncedAt: new Date(),
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
syncFailureCount: { increment: 1 },
},
});
// Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo
// mit abgelaufenem Token schlaegt sonst stuendlich neu auf.
if (!repository.syncError) {
await recordActivity({
projectId: repository.projectId,
kind: "SYNC_FAILED",
summary: `Abgleich von ${repoTitle(repository)} fehlgeschlagen: ${shorten(message, 100)}`,
});
}
await notifyAfterSync(repository.id);
return { ok: false, message };
}
}
export interface ProjectSyncResult extends SyncResult {
/** Ergebnis je Repository – auch dann, wenn nur eines dranhaengt. */
repositories: Array<{ id: string; title: string } & SyncResult>;
}
/**
* Gleicht alle Repositories eines Projekts ab, nacheinander.
*
* Nacheinander und nicht parallel: die Repos eines Projekts liegen meist
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
* das, worauf Rate-Limits reagieren.
*/
export async function syncProject(projectId: string): Promise<ProjectSyncResult> {
const repositories = await prisma.repository.findMany({
where: { projectId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
if (repositories.length === 0) {
return {
ok: false,
message: "Kein Repository hinterlegt – bitte eines verknuepfen.",
repositories: [],
};
}
const ergebnisse = [];
for (const repository of repositories) {
ergebnisse.push({
id: repository.id,
title: repoTitle(repository),
...(await syncRepository(repository.id)),
});
}
const fehler = ergebnisse.filter((entry) => !entry.ok);
return {
// Erfolgreich nur, wenn kein einziges Repo gescheitert ist. Ein "teilweise
// ok" waere eine Meldung, die man wegklickt, ohne hinzusehen.
ok: fehler.length === 0,
message:
fehler.length === 0
? ergebnisse.length === 1
? ergebnisse[0].message
: `${ergebnisse.length} Repositories abgeglichen.`
: fehler.map((entry) => `${entry.title}: ${entry.message}`).join(" · "),
fetchedAt: new Date().toISOString(),
repositories: ergebnisse,
};
}
/** 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">,
) {
if (repository.repoOwner && repository.repoName) {
return { owner: repository.repoOwner, name: repository.repoName };
}
if (repository.repoUrl) {
const parsed = parseRepoUrl(repository.repoUrl);
if (parsed) return { owner: parsed.owner, name: parsed.name };
}
return null;
}
/**
* Haelt neue Commits im Verlauf fest.
*
* Der Abgleich laeuft im Zweifel stuendlich, ein Eintrag je Durchlauf waere
* also unbrauchbar. Vermerkt wird nur, wenn sich der letzte Commit tatsaechlich
* geaendert hat – und beim allerersten Abgleich gar nicht: dass ein Repo
* Commits hat, ist keine Neuigkeit.
*/
async function noteNewCommits(
repository: Repository,
vorher: string | null,
data: RepoSnapshotData,
) {
const neuester = data.commits[0]?.sha ?? null;
if (!neuester || !vorher || neuester === vorher) return;
// Wie viele Commits liegen zwischen altem und neuem Stand? Steht der alte
// nicht mehr in der Liste, waren es mehr als eine Seite – dann lieber ohne
// Zahl als mit einer falschen.
const index = data.commits.findIndex((commit) => commit.sha === vorher);
const anzahl = index > 0 ? index : null;
const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository);
const betreff = shorten(data.commits[0]?.message ?? "", 60);
await recordActivity({
projectId: repository.projectId,
kind: "REPO_COMMITS",
summary:
anzahl === null
? `Neue Commits in ${wo}: ${betreff}`
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl },
});
}
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
const latestCommit = data.commits[0] ?? null;
const latestRelease =
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
const payload = {
fullName: data.fullName,
description: data.description,
defaultBranch: data.defaultBranch,
htmlUrl: data.htmlUrl,
visibility: data.visibility,
stars: data.stars,
forks: data.forks,
openIssues: data.openIssues,
openPullRequests: data.openPullRequests,
sizeKb: data.sizeKb,
lastCommitSha: latestCommit?.sha ?? null,
lastCommitMessage: latestCommit?.message ?? null,
lastCommitAuthor: latestCommit?.author ?? null,
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null,
latestTag,
latestRelease: latestRelease?.name ?? null,
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
branchCount: data.branches.length,
tagCount: data.tags.length,
branches: data.branches as unknown as object,
tags: data.tags as unknown as object,
commits: data.commits as unknown as object,
releases: data.releases as unknown as object,
languages: data.languages as unknown as object,
pullRequests: data.pullRequests as unknown as object,
issues: data.issues as unknown as object,
commitActivity: data.commitActivity as unknown as object,
ciState: data.ci?.state ?? null,
ciName: data.ci?.name ?? null,
ciBranch: data.ci?.branch ?? null,
ciUrl: data.ci?.url ?? null,
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
fetchedAt: new Date(),
};
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
const vorher = await prisma.repoSnapshot.findUnique({
where: { repositoryId: repository.id },
select: { lastCommitSha: true },
});
await prisma.$transaction([
prisma.repoSnapshot.upsert({
where: { repositoryId: repository.id },
create: { repositoryId: repository.id, ...payload },
update: payload,
}),
prisma.repository.update({
where: { id: repository.id },
data: {
lastSyncedAt: new Date(),
syncError: null,
syncFailureCount: 0,
defaultBranch: data.defaultBranch,
repoUrl: data.htmlUrl ?? undefined,
},
}),
// Das Projekt mit anfassen. Solange die Git-Felder daran hingen, geschah
// das nebenbei; jetzt muss es ausdruecklich passieren, sonst faellt ein
// regelmaessig abgeglichenes Projekt in der Sortierung nach hinten.
prisma.project.update({
where: { id: repository.projectId },
data: { updatedAt: new Date() },
}),
]);
await noteNewCommits(repository, vorher?.lastCommitSha ?? null, data);
}