feat(sync): automatischer Abgleich nach Zeitplan

Bisher musste jedes Projekt einzeln von Hand synchronisiert werden. Jetzt
gibt es einen Zeitgeber im Serverprozess, angestossen von
instrumentation.ts. Fuer eine Einzelinstanz ist das die einfachste
tragfaehige Loesung: kein zusaetzlicher Container, kein externer Cron, keine
zweite Konfiguration.

Ruecksicht auf fremde API-Kontingente war der bestimmende Gedanke:

- standardmaessig abgeschaltet - der Abgleich verbraucht fremdes Kontingent,
  das soll eine bewusste Entscheidung sein
- je Durchlauf nur eine begrenzte Zahl Projekte, die am laengsten nicht
  abgeglichenen zuerst; der Rest kommt beim naechsten Mal
- nach jedem Fehlschlag verdoppelt sich der Abstand bis zum naechsten
  Versuch, gedeckelt beim Sechzehnfachen. Ein Repo mit abgelaufenem Token
  blockiert damit nicht dauerhaft das Kontingent.
- kurze Pause zwischen zwei Projekten
- zwei Durchlaeufe ueberschneiden sich nie
- fertige und archivierte Projekte lassen sich auslassen
- einzelne Projekte lassen sich ausnehmen

Der Zaehler fuer Fehlschlaege wandert in syncProject: bei Erfolg zurueck auf
null, bei Fehlschlag hoch. So stimmt er auch nach einem manuellen Abgleich.

sync.ts wird zu sync/index.ts, damit Modul und Ordner nicht nebeneinander
stehen. Alle Importpfade bleiben unveraendert.

Zur Form der Abfrage in instrumentation.ts: Next uebersetzt die Datei auch
fuer den Edge-Runtime. Der Import muss deshalb *in* der positiven Bedingung
stehen, sonst bleibt er im Edge-Bundle stehen und der Build scheitert an
node:crypto. Mit einem frueh abbrechenden return passiert genau das.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:37:54 +02:00
co-authored by Claude Opus 5
parent b865cf49bd
commit ed2637877e
5 changed files with 236 additions and 2 deletions
+141
View File
@@ -0,0 +1,141 @@
import type { Project } from "@prisma/client";
import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult {
ok: boolean;
message: string;
fetchedAt?: string;
}
/**
* Holt den aktuellen Stand des verknuepften Repositories und schreibt ihn
* als Snapshot in die Datenbank. Fehler werden am Projekt vermerkt statt
* geworfen, damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
*/
export async function syncProject(projectId: string): Promise<SyncResult> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { connection: true },
});
if (!project) {
return { ok: false, message: "Projekt nicht gefunden." };
}
const ref = resolveRepoRef(project);
if (!ref) {
return {
ok: false,
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
};
}
let client;
if (project.connection) {
client = clientForConnection(project.connection);
} else {
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
const parsed = project.repoUrl ? parseRepoUrl(project.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(project.id, data);
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.project.update({
where: { id: project.id },
data: {
syncError: message,
lastSyncedAt: new Date(),
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
syncFailureCount: { increment: 1 },
},
});
return { ok: false, message };
}
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef(project: Project) {
if (project.repoOwner && project.repoName) {
return { owner: project.repoOwner, name: project.repoName };
}
if (project.repoUrl) {
const parsed = parseRepoUrl(project.repoUrl);
if (parsed) return { owner: parsed.owner, name: parsed.name };
}
return null;
}
async function persistSnapshot(projectId: string, 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,
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
fetchedAt: new Date(),
};
await prisma.$transaction([
prisma.repoSnapshot.upsert({
where: { projectId },
create: { projectId, ...payload },
update: payload,
}),
prisma.project.update({
where: { id: projectId },
data: {
lastSyncedAt: new Date(),
syncError: null,
syncFailureCount: 0,
defaultBranch: data.defaultBranch,
repoUrl: data.htmlUrl ?? undefined,
},
}),
]);
}