feat(git): mehrere Repositories je Projekt

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>
This commit is contained in:
2026-07-26 13:23:53 +02:00
co-authored by Claude Opus 5
parent 2bff6faf3d
commit a843f08211
29 changed files with 1207 additions and 268 deletions
+36 -22
View File
@@ -1,6 +1,7 @@
import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client";
import type { NotificationEvent, Project, RepoSnapshot, Repository } from "@prisma/client";
import { APP_NAME, APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { repoTitle } from "@/lib/repositories";
import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types";
@@ -14,25 +15,26 @@ export { emailAvailable, smtpConfig } from "./channels";
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
* Der zuletzt gemeldete Zustand steht am Projekt.
* Der zuletzt gemeldete Zustand steht am Repository – nicht am Projekt, sonst
* verdeckte ein rotes Frontend die rote Pipeline des Backends.
*
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
* darf den Abgleich nicht scheitern lassen.
*/
export async function notifyAfterSync(projectId: string): Promise<void> {
export async function notifyAfterSync(repositoryId: string): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { snapshot: true },
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: { snapshot: true, project: true },
});
if (!project?.ownerId) return;
if (!repository?.project.ownerId) return;
const faellig = pendingEvents(project, project.snapshot);
const faellig = pendingEvents(repository.project, repository, repository.snapshot);
if (faellig.length === 0) return;
const ziele = await prisma.notificationTarget.findMany({
where: {
userId: project.ownerId,
userId: repository.project.ownerId,
isActive: true,
events: { hasSome: faellig.map((entry) => entry.event) },
},
@@ -40,12 +42,12 @@ export async function notifyAfterSync(projectId: string): Promise<void> {
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
await prisma.project.update({
where: { id: project.id },
await prisma.repository.update({
where: { id: repository.id },
data: {
notifiedSyncError: project.syncError,
notifiedCiState: project.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(project.snapshot),
notifiedSyncError: repository.syncError,
notifiedCiState: repository.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(repository.snapshot),
},
});
@@ -81,6 +83,7 @@ function currentRelease(snapshot: RepoSnapshot | null): string | null {
*/
export function pendingEvents(
project: Project,
repository: Repository,
snapshot: RepoSnapshot | null,
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = [];
@@ -88,13 +91,24 @@ export function pendingEvents(
const bezug = { id: project.id, name: project.name, slug: project.slug };
const jetzt = new Date().toISOString();
if (project.syncError && project.syncError !== project.notifiedSyncError) {
/**
* Der Betreff nennt das Repository nur, wenn es nicht ohnehin so heisst
* wie das Projekt. "nebula – nebula: CI ist rot" liest niemand gern.
*/
const titel = repoTitle(repository);
const woher =
titel.toLowerCase() === project.name.toLowerCase() ||
titel.toLowerCase().endsWith(`/${project.name.toLowerCase()}`)
? project.name
: `${project.name} · ${titel}`;
if (repository.syncError && repository.syncError !== repository.notifiedSyncError) {
treffer.push({
event: "SYNC_FAILED",
message: {
event: "SYNC_FAILED",
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`,
body: project.syncError,
title: `${EVENT_META.SYNC_FAILED.emoji} ${woher}: Abgleich fehlgeschlagen`,
body: repository.syncError,
priority: "high",
url: projektUrl,
project: bezug,
@@ -104,12 +118,12 @@ export function pendingEvents(
}
const ci = snapshot?.ciState ?? null;
if (ci === "failure" && ci !== project.notifiedCiState) {
if (ci === "failure" && ci !== repository.notifiedCiState) {
treffer.push({
event: "CI_FAILED",
message: {
event: "CI_FAILED",
title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`,
title: `${EVENT_META.CI_FAILED.emoji} ${woher}: CI ist rot`,
body:
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
@@ -125,13 +139,13 @@ export function pendingEvents(
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
// vorher schon einmal etwas festgehalten wurde.
if (release && project.notifiedRelease && release !== project.notifiedRelease) {
if (release && repository.notifiedRelease && release !== repository.notifiedRelease) {
treffer.push({
event: "NEW_RELEASE",
message: {
event: "NEW_RELEASE",
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`,
title: `${EVENT_META.NEW_RELEASE.emoji} ${woher}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${repository.notifiedRelease}).`,
priority: "normal",
url: snapshot?.htmlUrl ?? projektUrl,
project: bezug,