Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene Tabelle, der Snapshot haengt jetzt am Repository. Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem* erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt. Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem seiner Teile gearbeitet wird. Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander - ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das, worauf Rate-Limits reagieren. Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere Aufgaben ohne Herkunft bleiben erlaubt. Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo, und der Import muss von der neuen Tabelle nichts wissen. Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt lesbar: dort stand genau ein Repository flach am Projekt. Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht. Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung, Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo bekommen keine leere Zeile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
269 lines
9.2 KiB
TypeScript
269 lines
9.2 KiB
TypeScript
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 { 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);
|
||
// 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,
|
||
};
|
||
}
|
||
|
||
/** 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);
|
||
}
|