From a843f08211bef716ed2fc830a6f89ee7ecb2ea26 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 13:23:53 +0200 Subject: [PATCH] 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) --- .../20260726230000_repositories/migration.sql | 113 ++++++++++++++ prisma/schema.prisma | 110 +++++++++----- src/app/admin/page.tsx | 4 +- src/app/api/connections/[id]/route.ts | 2 +- src/app/api/connections/route.ts | 2 +- src/app/api/projects/[id]/export/route.ts | 16 +- .../api/projects/[id]/repositories/route.ts | 122 +++++++++++++++ src/app/api/projects/[id]/route.ts | 51 +++---- .../projects/[id]/tasks/from-issues/route.ts | 37 ++++- src/app/api/projects/import/route.ts | 54 +++++-- src/app/api/projects/route.ts | 34 ++--- src/app/api/repositories/[id]/route.ts | 93 ++++++++++++ src/app/api/repositories/[id]/sync/route.ts | 41 +++++ src/app/connections/page.tsx | 2 +- src/app/page.tsx | 2 +- src/lib/export/format.ts | 44 +++++- src/lib/export/project.ts | 122 ++++++++++----- src/lib/git/meta.ts | 19 +++ src/lib/notify/index.ts | 58 ++++--- src/lib/notify/pending.test.ts | 79 +++++++--- src/lib/notify/stale.test.ts | 24 +++ src/lib/notify/stale.ts | 47 +++++- src/lib/projects.ts | 17 ++- src/lib/repositories.test.ts | 55 +++++++ src/lib/repositories.ts | 116 ++++++++++++++ src/lib/sync/index.ts | 143 +++++++++++++----- src/lib/sync/scheduler.ts | 29 ++-- src/lib/validation.ts | 22 +++ src/types/index.ts | 17 ++- 29 files changed, 1207 insertions(+), 268 deletions(-) create mode 100644 prisma/migrations/20260726230000_repositories/migration.sql create mode 100644 src/app/api/projects/[id]/repositories/route.ts create mode 100644 src/app/api/repositories/[id]/route.ts create mode 100644 src/app/api/repositories/[id]/sync/route.ts create mode 100644 src/lib/repositories.test.ts create mode 100644 src/lib/repositories.ts diff --git a/prisma/migrations/20260726230000_repositories/migration.sql b/prisma/migrations/20260726230000_repositories/migration.sql new file mode 100644 index 0000000..02d908c --- /dev/null +++ b/prisma/migrations/20260726230000_repositories/migration.sql @@ -0,0 +1,113 @@ +-- Mehrere Repositories je Projekt. +-- +-- Die Git-Felder wandern vom Projekt in eine eigene Tabelle. Bestehende +-- Projekte bekommen daraus genau ein Repository, damit nichts verlorengeht; +-- der Snapshot haengt danach am Repository statt am Projekt. +-- +-- Von Hand geschrieben: prisma migrate diff wuerde die Spalten am Projekt +-- ersatzlos loeschen und die Snapshots gleich mit. + +-- ── 1. Neue Tabelle ───────────────────────────────────────── +CREATE TABLE "Repository" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "label" TEXT, + "repoUrl" TEXT, + "repoOwner" TEXT, + "repoName" TEXT, + "defaultBranch" TEXT, + "connectionId" TEXT, + "position" INTEGER NOT NULL DEFAULT 0, + "lastSyncedAt" TIMESTAMP(3), + "syncError" TEXT, + "autoSync" BOOLEAN NOT NULL DEFAULT true, + "syncFailureCount" INTEGER NOT NULL DEFAULT 0, + "notifiedSyncError" TEXT, + "notifiedCiState" TEXT, + "notifiedRelease" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Repository_projectId_position_idx" ON "Repository"("projectId", "position"); +CREATE INDEX "Repository_connectionId_idx" ON "Repository"("connectionId"); + +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_connectionId_fkey" + FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- ── 2. Bestand uebernehmen ────────────────────────────────── +-- Nur Projekte, an denen wirklich etwas haengt. Ein Projekt, das bloss eine +-- Verbindung ausgewaehlt hatte, ohne je ein Repo zu nennen, braucht keine +-- leere Zeile. +INSERT INTO "Repository" ( + "id", "projectId", "repoUrl", "repoOwner", "repoName", "defaultBranch", + "connectionId", "position", "lastSyncedAt", "syncError", "autoSync", + "syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease", + "createdAt", "updatedAt" +) +SELECT + gen_random_uuid()::text, "id", "repoUrl", "repoOwner", "repoName", "defaultBranch", + "connectionId", 0, "lastSyncedAt", "syncError", "autoSync", + "syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease", + "createdAt", CURRENT_TIMESTAMP +FROM "Project" +WHERE "repoUrl" IS NOT NULL + OR ("repoOwner" IS NOT NULL AND "repoName" IS NOT NULL); + +-- ── 3. Snapshots umhaengen ────────────────────────────────── +ALTER TABLE "RepoSnapshot" ADD COLUMN "repositoryId" TEXT; + +UPDATE "RepoSnapshot" AS s +SET "repositoryId" = r."id" +FROM "Repository" AS r +WHERE r."projectId" = s."projectId"; + +-- Ein Snapshot ohne zugehoeriges Repository kann nur entstanden sein, wenn +-- jemand die Repo-Angaben nachtraeglich geleert hat. Er ist ohnehin nicht +-- mehr zuzuordnen und beim naechsten Abgleich neu geholt. +DELETE FROM "RepoSnapshot" WHERE "repositoryId" IS NULL; + +ALTER TABLE "RepoSnapshot" ALTER COLUMN "repositoryId" SET NOT NULL; +ALTER TABLE "RepoSnapshot" DROP CONSTRAINT "RepoSnapshot_projectId_fkey"; +DROP INDEX "RepoSnapshot_projectId_key"; +ALTER TABLE "RepoSnapshot" DROP COLUMN "projectId"; + +CREATE UNIQUE INDEX "RepoSnapshot_repositoryId_key" ON "RepoSnapshot"("repositoryId"); +ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_repositoryId_fkey" + FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ── 4. Herkunft uebernommener Aufgaben ────────────────────── +-- Bisher war die Issue-Nummer je Projekt eindeutig. Mit mehreren Repos +-- reicht das nicht mehr: Frontend und Backend haben beide ein Issue 5. +ALTER TABLE "Task" ADD COLUMN "sourceRepositoryId" TEXT; + +UPDATE "Task" AS t +SET "sourceRepositoryId" = r."id" +FROM "Repository" AS r +WHERE r."projectId" = t."projectId" AND t."sourceIssue" IS NOT NULL; + +DROP INDEX "Task_projectId_sourceIssue_key"; +CREATE UNIQUE INDEX "Task_projectId_sourceRepositoryId_sourceIssue_key" + ON "Task"("projectId", "sourceRepositoryId", "sourceIssue"); + +-- ── 5. Projekt aufraeumen ─────────────────────────────────── +ALTER TABLE "Project" DROP CONSTRAINT "Project_connectionId_fkey"; +DROP INDEX "Project_connectionId_idx"; + +ALTER TABLE "Project" + DROP COLUMN "repoUrl", + DROP COLUMN "repoOwner", + DROP COLUMN "repoName", + DROP COLUMN "defaultBranch", + DROP COLUMN "connectionId", + DROP COLUMN "lastSyncedAt", + DROP COLUMN "syncError", + DROP COLUMN "autoSync", + DROP COLUMN "syncFailureCount", + DROP COLUMN "notifiedSyncError", + DROP COLUMN "notifiedCiState", + DROP COLUMN "notifiedRelease"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a21726..7ece2c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -286,7 +286,7 @@ model GitConnection { ownerId String? owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) - projects Project[] + repositories Repository[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -316,24 +316,6 @@ model Project { tags String[] @default([]) favorite Boolean @default(false) - // ── Git-Anbindung ────────────────────────────────────────── - repoUrl String? - /// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe") - repoOwner String? - repoName String? - defaultBranch String? - connectionId String? - connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) - - lastSyncedAt DateTime? - syncError String? - /// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei - /// einem Repo, dessen Token staendig Fehler wirft. - autoSync Boolean @default(true) - /// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach - /// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert. - syncFailureCount Int @default(0) - /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Einrichten des ersten Kontos uebernommen werden koennen. ownerId String? @@ -343,31 +325,78 @@ model Project { /// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt. progressFromTasks Boolean @default(false) - /// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer - /// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem - /// Abgleich dieselbe Nachricht. - notifiedSyncError String? - notifiedCiState String? - notifiedRelease String? /// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird /// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde. - notifiedStaleAt DateTime? + /// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht + /// ueber ein einzelnes Repository. + notifiedStaleAt DateTime? - notes Note[] - tasks Task[] - snapshot RepoSnapshot? - activities Activity[] + notes Note[] + tasks Task[] + activities Activity[] + repositories Repository[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([ownerId, slug]) @@index([status]) - @@index([connectionId]) @@index([updatedAt]) @@index([ownerId]) } +/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren – +/// Frontend und Backend etwa –, gehoert aber in eine Karte. +/// +/// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen +/// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist. +model Repository { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + /// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name + /// steht fuer sich. + label String? + + repoUrl String? + /// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe") + repoOwner String? + repoName String? + defaultBranch String? + + connectionId String? + connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) + + /// Reihenfolge in der Anzeige. + position Int @default(0) + + lastSyncedAt DateTime? + syncError String? + /// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei + /// einem Repo, dessen Token staendig Fehler wirft. + autoSync Boolean @default(true) + /// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach + /// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert. + syncFailureCount Int @default(0) + + /// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer + /// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem + /// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst + /// verdeckt ein rotes Frontend die rote Pipeline des Backends. + notifiedSyncError String? + notifiedCiState String? + notifiedRelease String? + + snapshot RepoSnapshot? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId, position]) + @@index([connectionId]) +} + /// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto. model NotificationTarget { id String @id @default(cuid()) @@ -416,17 +445,20 @@ model Task { /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. doneAt DateTime? - /// Stammt die Aufgabe aus einem Git-Issue? Nummer und Link merken, damit - /// dasselbe Issue nicht zweimal uebernommen wird. + /// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link + /// merken, damit dasselbe Issue nicht zweimal uebernommen wird. sourceIssue Int? sourceUrl String? + /// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und + /// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben. + sourceRepositoryId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt /// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne /// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden. - @@unique([projectId, sourceIssue]) + @@unique([projectId, sourceRepositoryId, sourceIssue]) @@index([projectId, status, position]) } @@ -471,12 +503,12 @@ model Note { @@index([pinned]) } -/// Zwischengespeicherter Stand des verknuepften Repositories. Wird beim -/// Sync ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt. +/// Zwischengespeicherter Stand eines Repositories. Wird beim Sync +/// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt. model RepoSnapshot { - id String @id @default(cuid()) - projectId String @unique - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + repositoryId String @unique + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) fullName String? description String? diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e9e2f1e..a8654ef 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -27,7 +27,9 @@ export default async function AdminPage() { }), // Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine // Dateneinsicht. Gezaehlt wird nur, nicht gelesen. - prisma.project.count({ + // Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in + // der abgeglichen und begrenzt wird. + prisma.repository.count({ where: { autoSync: true, OR: [ diff --git a/src/app/api/connections/[id]/route.ts b/src/app/api/connections/[id]/route.ts index cc11c43..c5019e1 100644 --- a/src/app/api/connections/[id]/route.ts +++ b/src/app/api/connections/[id]/route.ts @@ -14,7 +14,7 @@ type Params = { params: Promise<{ id: string }> }; const safeSelect = { ...connectionSafeSelect, - _count: { select: { projects: true } }, + _count: { select: { repositories: true } }, }; export async function PATCH(request: Request, { params }: Params) { diff --git a/src/app/api/connections/route.ts b/src/app/api/connections/route.ts index 75cf070..67e290b 100644 --- a/src/app/api/connections/route.ts +++ b/src/app/api/connections/route.ts @@ -14,7 +14,7 @@ export const dynamic = "force-dynamic"; /** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */ const safeSelect = { ...connectionSafeSelect, - _count: { select: { projects: true } }, + _count: { select: { repositories: true } }, }; export async function GET() { diff --git a/src/app/api/projects/[id]/export/route.ts b/src/app/api/projects/[id]/export/route.ts index 67b71a9..cef9c90 100644 --- a/src/app/api/projects/[id]/export/route.ts +++ b/src/app/api/projects/[id]/export/route.ts @@ -1,7 +1,7 @@ import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; import { exportFileName } from "@/lib/export/format"; -import { buildExportFile, toMarkdown } from "@/lib/export/project"; +import { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project"; import { jsonError } from "@/lib/security/request"; export const dynamic = "force-dynamic"; @@ -24,10 +24,16 @@ export async function GET(request: Request, { params }: Params) { const project = await prisma.project.findFirst({ where: { id, ownerId: auth.user.id }, include: { - notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, - tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, - connection: { select: { name: true, provider: true, baseUrl: true } }, - snapshot: true, + ...exportInclude, + // Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit + // die Datei auch ohne Netz vollstaendig ist. + repositories: { + include: { + connection: { select: { name: true, provider: true, baseUrl: true } }, + snapshot: true, + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, }, }); diff --git a/src/app/api/projects/[id]/repositories/route.ts b/src/app/api/projects/[id]/repositories/route.ts new file mode 100644 index 0000000..0219066 --- /dev/null +++ b/src/app/api/projects/[id]/repositories/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import { recordActivity, shorten } from "@/lib/activity"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { connectionSafeSelect } from "@/lib/projects"; +import { + completeRepoInput, + namesRepository, + nextRepoPosition, + repoTitle, +} from "@/lib/repositories"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { ownedProject } from "@/lib/tasks"; +import { formatZodError, repositoryCreateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */ +const MAX_REPOS = 20; + +const include = { + connection: { select: connectionSafeSelect }, + snapshot: true, +} as const; + +export async function GET(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + if (!(await ownedProject(id, auth.user.id))) { + return jsonError("Projekt nicht gefunden", 404); + } + + const repositories = await prisma.repository.findMany({ + where: { projectId: id }, + include, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }); + + return NextResponse.json({ repositories }); +} + +export async function POST(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + if (!(await ownedProject(id, auth.user.id))) { + return jsonError("Projekt nicht gefunden", 404); + } + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = repositoryCreateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const eingabe = completeRepoInput(parsed.data); + if (!namesRepository(eingabe)) { + return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422); + } + + // Eine fremde Verbindung darf nicht untergeschoben werden. + if (eingabe.connectionId) { + const connection = await prisma.gitConnection.findFirst({ + where: { id: eingabe.connectionId, ownerId: auth.user.id }, + select: { id: true }, + }); + if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); + } + + const vorhanden = await prisma.repository.count({ where: { projectId: id } }); + if (vorhanden >= MAX_REPOS) { + return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409); + } + + // Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen. + if (eingabe.repoOwner && eingabe.repoName) { + const doppelt = await prisma.repository.findFirst({ + where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName }, + select: { id: true }, + }); + if (doppelt) { + return jsonError( + `${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`, + 409, + ); + } + } + + const repository = await prisma.repository.create({ + data: { + projectId: id, + label: eingabe.label ?? null, + repoUrl: eingabe.repoUrl ?? null, + repoOwner: eingabe.repoOwner ?? null, + repoName: eingabe.repoName ?? null, + connectionId: eingabe.connectionId ?? null, + autoSync: eingabe.autoSync ?? true, + position: await nextRepoPosition(id), + }, + include, + }); + + await recordActivity({ + projectId: id, + userId: auth.user.id, + kind: "REPO_LINKED", + summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`, + meta: { repositoryId: repository.id }, + }); + + return NextResponse.json({ repository }, { status: 201 }); +} diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index ee61be1..f755a16 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -3,8 +3,8 @@ import { NextResponse } from "next/server"; import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { parseRepoUrl } from "@/lib/git"; import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; +import { applyLeadingRepo, type RepoInput } from "@/lib/repositories"; import { jsonError, readJsonBody } from "@/lib/security/request"; import { refreshDerivedProgress } from "@/lib/tasks"; import { formatZodError, projectUpdateSchema } from "@/lib/validation"; @@ -77,30 +77,26 @@ export async function PATCH(request: Request, { params }: Params) { "accent", "tags", "favorite", - "autoSync", "progressFromTasks", - "connectionId", ] as const) { if (data[key] !== undefined) update[key] = data[key]; } - if (data.repoUrl !== undefined) { - update.repoUrl = data.repoUrl; - const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null; - if (parsedUrl) { - update.repoOwner = data.repoOwner ?? parsedUrl.owner; - update.repoName = data.repoName ?? parsedUrl.name; - } else if (!data.repoUrl) { - update.repoOwner = null; - update.repoName = null; - } - } - if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner; - if (data.repoName !== undefined) update.repoName = data.repoName; - await prisma.project.update({ where: { id }, data: update }); - await noteChanges(id, auth.user.id, existing, update); + // Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin + // anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang. + const repoFelder = { + repoUrl: data.repoUrl, + repoOwner: data.repoOwner, + repoName: data.repoName, + connectionId: data.connectionId, + autoSync: data.autoSync, + }; + const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined); + if (repoBeruehrt) await applyLeadingRepo(id, repoFelder); + + await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null); // Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht // erst beim naechsten Aufgabenwechsel. @@ -125,12 +121,11 @@ export async function PATCH(request: Request, { params }: Params) { async function noteChanges( projectId: string, userId: string, - before: { status: ProjectStatus; repoUrl: string | null }, + before: { status: ProjectStatus }, update: Record, + repo: RepoInput | null, ) { - const rest = Object.keys(update).filter( - (key) => key !== "status" && key !== "slug" && !key.startsWith("repo"), - ); + const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug"); const entries: Parameters[0] = []; if (update.status !== undefined && update.status !== before.status) { @@ -144,16 +139,20 @@ async function noteChanges( }); } - if (update.repoUrl !== undefined && update.repoUrl !== before.repoUrl) { - const url = update.repoUrl as string | null; + // Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische + // Abgleich an oder aus ist, gehoert zu den uebrigen Feldern. + if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) { + const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null); entries.push({ projectId, userId, kind: "REPO_LINKED", - summary: url ? `Repository verknüpft: ${shorten(url)}` : "Repository-Verknüpfung entfernt", - meta: { repoUrl: url }, + summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert", + meta: { repoUrl: repo.repoUrl ?? null }, }); } + if (repo?.autoSync !== undefined) rest.push("autoSync"); + if (repo?.connectionId !== undefined) rest.push("connectionId"); const felder = describeFields(rest); if (felder) { diff --git a/src/app/api/projects/[id]/tasks/from-issues/route.ts b/src/app/api/projects/[id]/tasks/from-issues/route.ts index 1846d4a..5faf196 100644 --- a/src/app/api/projects/[id]/tasks/from-issues/route.ts +++ b/src/app/api/projects/[id]/tasks/from-issues/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { recordActivity } from "@/lib/activity"; +import { repoTitle } from "@/lib/repositories"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; import { jsonError, readJsonBody } from "@/lib/security/request"; @@ -15,6 +16,8 @@ type Params = { params: Promise<{ id: string }> }; const schema = z.object({ /** Leer heisst: alle offenen Issues aus dem Snapshot. */ numbers: z.array(z.number().int()).max(200).optional(), + /** Aus welchem Repository. Leer heisst: dem fuehrenden. */ + repositoryId: z.string().optional(), }); /** @@ -49,7 +52,23 @@ export async function POST(request: Request, { params }: Params) { ); } - const snapshot = await prisma.repoSnapshot.findUnique({ where: { projectId: id } }); + // Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben + // geprueft wurde – eine fremde Repository-ID trifft hier also nichts. + const repository = await prisma.repository.findFirst({ + where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + include: { snapshot: true }, + }); + + if (!repository) { + // 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt, + // hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden". + return parsed.data.repositoryId + ? jsonError("Repository nicht gefunden", 404) + : jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409); + } + + const snapshot = repository.snapshot; if (!snapshot) { return jsonError( "Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.", @@ -68,7 +87,11 @@ export async function POST(request: Request, { params }: Params) { } const vorhanden = await prisma.task.findMany({ - where: { projectId: id, sourceIssue: { in: auswahl.map((issue) => issue.number) } }, + where: { + projectId: id, + sourceRepositoryId: repository.id, + sourceIssue: { in: auswahl.map((issue) => issue.number) }, + }, select: { sourceIssue: true }, }); const bekannt = new Set(vorhanden.map((task) => task.sourceIssue)); @@ -90,6 +113,7 @@ export async function POST(request: Request, { params }: Params) { labels: issue.labels.slice(0, 8), sourceIssue: issue.number, sourceUrl: issue.url, + sourceRepositoryId: repository.id, }, }), ); @@ -106,9 +130,12 @@ export async function POST(request: Request, { params }: Params) { kind: "TASKS_IMPORTED", summary: angelegt.length === 1 - ? "1 Issue als Aufgabe übernommen" - : `${angelegt.length} Issues als Aufgaben übernommen`, - meta: { numbers: angelegt.map((task) => task.sourceIssue) }, + ? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen` + : `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`, + meta: { + repositoryId: repository.id, + numbers: angelegt.map((task) => task.sourceIssue), + }, }); } diff --git a/src/app/api/projects/import/route.ts b/src/app/api/projects/import/route.ts index bde1025..40e5082 100644 --- a/src/app/api/projects/import/route.ts +++ b/src/app/api/projects/import/route.ts @@ -3,7 +3,11 @@ import { z } from "zod"; import { recordActivity } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { exportFileSchema, type ExportedProject } from "@/lib/export/format"; +import { + exportFileSchema, + repositoriesOf, + type ExportedRepository, +} from "@/lib/export/format"; import { uniqueSlug } from "@/lib/projects"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { @@ -85,7 +89,7 @@ export async function POST(request: Request) { select: { id: true, name: true, provider: true, baseUrl: true }, }); - function matchConnection(described: ExportedProject["connection"]) { + function matchConnection(described: ExportedRepository["connection"]) { if (!described) return null; const byName = connections.find( (candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(), @@ -105,12 +109,26 @@ export async function POST(request: Request) { for (const incoming of file.projects) { const slug = incoming.slug || slugify(incoming.name) || "projekt"; - const connection = matchConnection(incoming.connection); - const connectionState: ItemResult["connection"] = incoming.connection - ? connection - ? "matched" - : "missing" - : "none"; + + // Version 1 kannte ein Repository je Projekt und schrieb es flach; seit + // Version 2 steht eine Liste da. repositoriesOf glaettet den Unterschied. + const eingehendeRepos = repositoriesOf(incoming); + const repos = eingehendeRepos.map((repo, index) => ({ + label: repo.label ?? null, + repoUrl: repo.repoUrl ?? null, + repoOwner: repo.repoOwner ?? null, + repoName: repo.repoName ?? null, + autoSync: repo.autoSync ?? true, + connectionId: matchConnection(repo.connection ?? null)?.id ?? null, + position: index, + })); + + // Fuer die Rueckmeldung genuegt, ob ueberhaupt eine beschriebene + // Verbindung fehlt – welche, steht in der Datei. + const beschrieben = eingehendeRepos.filter((repo) => repo.connection); + const fehlend = beschrieben.filter((repo) => !matchConnection(repo.connection ?? null)); + const connectionState: ItemResult["connection"] = + beschrieben.length === 0 ? "none" : fehlend.length > 0 ? "missing" : "matched"; const existing = await prisma.project.findFirst({ where: { slug, ownerId: auth.user.id }, @@ -123,7 +141,7 @@ export async function POST(request: Request) { action: "create", notes: incoming.notes.length, connection: connectionState, - connectionName: incoming.connection?.name, + connectionName: (fehlend[0] ?? beschrieben[0])?.connection?.name, }; if (existing && strategy === "skip") { @@ -155,11 +173,6 @@ export async function POST(request: Request) { accent: incoming.accent, tags: incoming.tags, favorite: incoming.favorite, - autoSync: incoming.autoSync, - repoUrl: incoming.repoUrl ?? null, - repoOwner: incoming.repoOwner ?? null, - repoName: incoming.repoName ?? null, - connectionId: connection?.id ?? null, progressFromTasks: incoming.progressFromTasks, }; @@ -195,9 +208,19 @@ export async function POST(request: Request) { await prisma.$transaction([ prisma.note.deleteMany({ where: { projectId: existing.id } }), prisma.task.deleteMany({ where: { projectId: existing.id } }), + // Wie Notizen und Aufgaben: austauschen statt zusammenfuehren. Der + // gesammelte Repo-Stand geht dabei mit, ist aber beim naechsten + // Abgleich zurueck. + prisma.repository.deleteMany({ where: { projectId: existing.id } }), prisma.project.update({ where: { id: existing.id }, - data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } }, + data: { + ...data, + slug, + notes: { create: notes }, + tasks: { create: tasks }, + repositories: { create: repos }, + }, }), ]); results.push({ ...base, action: "overwrite" }); @@ -210,6 +233,7 @@ export async function POST(request: Request) { slug: finalSlug, notes: { create: notes }, tasks: { create: tasks }, + repositories: { create: repos }, }, }); // Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 0d890ef..76049f9 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import { recordActivity } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { parseRepoUrl } from "@/lib/git"; import { projectListInclude, uniqueSlug } from "@/lib/projects"; +import { applyLeadingRepo } from "@/lib/repositories"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { statusOf } from "@/lib/status"; @@ -74,18 +74,7 @@ export async function POST(request: Request) { if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); } - // Owner/Name aus der URL ableiten, falls nicht explizit angegeben. - let repoOwner = data.repoOwner ?? null; - let repoName = data.repoName ?? null; - if (data.repoUrl && (!repoOwner || !repoName)) { - const parsedUrl = parseRepoUrl(data.repoUrl); - if (parsedUrl) { - repoOwner = repoOwner ?? parsedUrl.owner; - repoName = repoName ?? parsedUrl.name; - } - } - - const project = await prisma.project.create({ + const angelegt = await prisma.project.create({ data: { ownerId: auth.user.id, name: data.name, @@ -98,12 +87,21 @@ export async function POST(request: Request) { accent: data.accent, tags: data.tags, favorite: data.favorite, - autoSync: data.autoSync, - repoUrl: data.repoUrl ?? null, - repoOwner, - repoName, - connectionId: data.connectionId ?? null, }, + select: { id: true }, + }); + + // Die Git-Angaben werden zum fuehrenden Repository – falls welche kamen. + await applyLeadingRepo(angelegt.id, { + repoUrl: data.repoUrl, + repoOwner: data.repoOwner, + repoName: data.repoName, + connectionId: data.connectionId, + autoSync: data.autoSync, + }); + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: angelegt.id }, include: projectListInclude, }); diff --git a/src/app/api/repositories/[id]/route.ts b/src/app/api/repositories/[id]/route.ts new file mode 100644 index 0000000..f97fb0d --- /dev/null +++ b/src/app/api/repositories/[id]/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { recordActivity, shorten } from "@/lib/activity"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { connectionSafeSelect } from "@/lib/projects"; +import { completeRepoInput, ownedRepository, repoTitle } from "@/lib/repositories"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, repositoryUpdateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const existing = await ownedRepository(id, auth.user.id); + if (!existing) return jsonError("Repository nicht gefunden", 404); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = repositoryUpdateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const eingabe = completeRepoInput(parsed.data); + + if (eingabe.connectionId) { + const connection = await prisma.gitConnection.findFirst({ + where: { id: eingabe.connectionId, ownerId: auth.user.id }, + select: { id: true }, + }); + if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); + } + + const data: Record = {}; + for (const key of ["label", "repoUrl", "repoOwner", "repoName", "connectionId", "autoSync"] as const) { + if (eingabe[key] !== undefined) data[key] = eingabe[key]; + } + if (parsed.data.position !== undefined) data.position = parsed.data.position; + + // Zeigt das Repo woandershin, taugt der gesammelte Stand nicht mehr. Ihn + // stehen zu lassen waere schlimmer als ihn zu verlieren: er saehe echt aus. + const zieltWoandershin = + (eingabe.repoOwner !== undefined && eingabe.repoOwner !== existing.repoOwner) || + (eingabe.repoName !== undefined && eingabe.repoName !== existing.repoName); + if (zieltWoandershin) { + data.lastSyncedAt = null; + data.syncError = null; + data.syncFailureCount = 0; + data.notifiedSyncError = null; + data.notifiedCiState = null; + data.notifiedRelease = null; + await prisma.repoSnapshot.deleteMany({ where: { repositoryId: id } }); + } + + const repository = await prisma.repository.update({ + where: { id }, + data, + include: { connection: { select: connectionSafeSelect }, snapshot: true }, + }); + + return NextResponse.json({ repository }); +} + +export async function DELETE(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + + // Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging. + const vorher = await ownedRepository(id, auth.user.id); + if (!vorher) return jsonError("Repository nicht gefunden", 404); + + await prisma.repository.delete({ where: { id } }); + + await recordActivity({ + projectId: vorher.projectId, + userId: auth.user.id, + kind: "REPO_LINKED", + summary: `Repository entfernt: ${shorten(repoTitle(vorher))}`, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/repositories/[id]/sync/route.ts b/src/app/api/repositories/[id]/sync/route.ts new file mode 100644 index 0000000..79f59a8 --- /dev/null +++ b/src/app/api/repositories/[id]/sync/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { connectionSafeSelect } from "@/lib/projects"; +import { ownedRepository } from "@/lib/repositories"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request"; +import { syncRepository } from "@/lib/sync"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** Gleicht ein einzelnes Repository ab. */ +export async function POST(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + // Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger + // begrenzt als bei reinen Datenbankzugriffen. + const limited = rateLimit( + `outbound:${auth.user.id}:${clientIp(request)}`, + LIMITS.outbound.limit, + LIMITS.outbound.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const { id } = await params; + if (!(await ownedRepository(id, auth.user.id))) { + return jsonError("Repository nicht gefunden", 404); + } + + const result = await syncRepository(id); + + const repository = await prisma.repository.findUnique({ + where: { id }, + include: { connection: { select: connectionSafeSelect }, snapshot: true }, + }); + + return NextResponse.json({ ...result, repository }, { status: result.ok ? 200 : 502 }); +} diff --git a/src/app/connections/page.tsx b/src/app/connections/page.tsx index 8313975..b3f8d5d 100644 --- a/src/app/connections/page.tsx +++ b/src/app/connections/page.tsx @@ -17,7 +17,7 @@ export default async function ConnectionsPage() { const connections = await prisma.gitConnection.findMany({ where: { ownerId: user.id }, - select: { ...connectionSafeSelect, _count: { select: { projects: true } } }, + select: { ...connectionSafeSelect, _count: { select: { repositories: true } } }, orderBy: { name: "asc" }, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index f6e35aa..826c7d9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -29,7 +29,7 @@ export default async function DashboardPage() { const ideas = active.filter((project) => project.status === "IDEA").length; const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length; const done = active.filter((project) => project.status === "DONE").length; - const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length; + const linked = active.filter((project) => project.repositories.length > 0).length; const averageProgress = active.length > 0 diff --git a/src/lib/export/format.ts b/src/lib/export/format.ts index 643d8a7..825bcac 100644 --- a/src/lib/export/format.ts +++ b/src/lib/export/format.ts @@ -18,7 +18,11 @@ import { z } from "zod"; */ export const EXPORT_KIND = "nebula.projects"; -export const EXPORT_VERSION = 1; +/** + * 2 seit den mehreren Repositories je Projekt. Dateien der Version 1 werden + * weiter gelesen: ihre einzelnen Repo-Felder werden zum ersten Repository. + */ +export const EXPORT_VERSION = 2; const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; @@ -46,6 +50,16 @@ export const exportedConnectionSchema = z.object({ baseUrl: z.string().max(300), }); +export const exportedRepositorySchema = z.object({ + label: z.string().max(80).nullable().optional(), + repoUrl: z.string().max(500).nullable().optional(), + repoOwner: z.string().max(200).nullable().optional(), + repoName: z.string().max(200).nullable().optional(), + autoSync: z.boolean().optional().default(true), + /** Nur zur Wiedererkennung – ohne Token. */ + connection: exportedConnectionSchema.nullable().optional(), +}); + export const exportedProjectSchema = z.object({ name: z.string().min(1).max(120), slug: z.string().max(60).optional(), @@ -57,11 +71,17 @@ export const exportedProjectSchema = z.object({ accent: z.string().max(20).optional().default("violet"), tags: z.array(z.string().max(30)).max(12).optional().default([]), favorite: z.boolean().optional().default(false), + /** Seit Version 2. */ + repositories: z.array(exportedRepositorySchema).max(20).optional(), + /** + * Version 1 kannte genau ein Repository und schrieb es flach ans Projekt. + * Die Felder bleiben lesbar, damit aeltere Dateien weiter importierbar + * sind; geschrieben werden sie nicht mehr. + */ autoSync: z.boolean().optional().default(true), repoUrl: z.string().max(500).nullable().optional(), repoOwner: z.string().max(200).nullable().optional(), repoName: z.string().max(200).nullable().optional(), - /** Nur zur Wiedererkennung – ohne Token. */ connection: exportedConnectionSchema.nullable().optional(), notes: z.array(exportedNoteSchema).max(500).optional().default([]), tasks: z.array(exportedTaskSchema).max(1000).optional().default([]), @@ -78,6 +98,26 @@ export const exportFileSchema = z.object({ }); export type ExportedProject = z.infer; +export type ExportedRepository = z.infer; + +/** + * Die Repositories einer Exportdatei – gleich, ob sie aus Version 2 stammt + * oder aus der Zeit mit genau einem Repo je Projekt. + */ +export function repositoriesOf(project: ExportedProject): ExportedRepository[] { + if (project.repositories?.length) return project.repositories; + if (!project.repoUrl && !(project.repoOwner && project.repoName)) return []; + return [ + { + label: null, + repoUrl: project.repoUrl ?? null, + repoOwner: project.repoOwner ?? null, + repoName: project.repoName ?? null, + autoSync: project.autoSync, + connection: project.connection ?? null, + }, + ]; +} export type ExportFile = z.infer; /** Dateiname ohne Sonderzeichen, mit Datumsstempel. */ diff --git a/src/lib/export/project.ts b/src/lib/export/project.ts index 1816a70..1abb9ab 100644 --- a/src/lib/export/project.ts +++ b/src/lib/export/project.ts @@ -1,14 +1,27 @@ -import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client"; +import type { + GitConnection, + Note, + Prisma, + Project, + RepoSnapshot, + Repository, + Task, +} from "@prisma/client"; import { APP_NAME } from "@/lib/config"; import { prisma } from "@/lib/db"; +import { repoTitle } from "@/lib/repositories"; import { PRIORITY_META, statusOf } from "@/lib/status"; import { formatDate, formatDateTime } from "@/lib/utils"; import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format"; -type ProjectForExport = Project & { +export type RepositoryForExport = Repository & { + connection: Pick | null; +}; + +export type ProjectForExport = Project & { notes: Note[]; tasks: Task[]; - connection: Pick | null; + repositories: RepositoryForExport[]; }; /** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */ @@ -24,17 +37,25 @@ export function toExportedProject(project: ProjectForExport): ExportedProject { accent: project.accent, tags: project.tags, favorite: project.favorite, - autoSync: project.autoSync, - repoUrl: project.repoUrl, - repoOwner: project.repoOwner, - repoName: project.repoName, - connection: project.connection - ? { - name: project.connection.name, - provider: project.connection.provider, - baseUrl: project.connection.baseUrl, - } - : null, + // Version 1 hatte ein autoSync am Projekt. Das Feld ist im Schema noch + // vorhanden, damit alte Dateien lesbar bleiben; geschrieben wird der + // Wert des fuehrenden Repositories, damit eine Version-2-Datei auch von + // einer aelteren Instanz noch sinnvoll gelesen werden koennte. + autoSync: project.repositories[0]?.autoSync ?? true, + repositories: project.repositories.map((repository) => ({ + label: repository.label, + repoUrl: repository.repoUrl, + repoOwner: repository.repoOwner, + repoName: repository.repoName, + autoSync: repository.autoSync, + connection: repository.connection + ? { + name: repository.connection.name, + provider: repository.connection.provider, + baseUrl: repository.connection.baseUrl, + } + : null, + })), notes: project.notes.map((note) => ({ title: note.title, content: note.content, @@ -72,15 +93,21 @@ export async function loadProjectsForExport( ): Promise { return prisma.project.findMany({ where: { ownerId, ...(projectId ? { id: projectId } : {}) }, - include: { - notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, - tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, - connection: { select: { name: true, provider: true, baseUrl: true } }, - }, + include: exportInclude, orderBy: { name: "asc" }, }); } +/** Der Umfang, den der Export braucht – an beiden Aufrufstellen derselbe. */ +export const exportInclude = { + notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, + repositories: { + include: { connection: { select: { name: true, provider: true, baseUrl: true } } }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, +} satisfies Prisma.ProjectInclude; + // ── Markdown ───────────────────────────────────────────────── const PROVIDER_LABEL: Record = { @@ -102,7 +129,9 @@ const TASK_LABEL: Record = { * Datei auch ohne Netz vollstaendig ist. */ export function toMarkdown( - project: ProjectForExport & { snapshot: RepoSnapshot | null }, + project: ProjectForExport & { + repositories: Array; + }, ): string { const status = statusOf(project.status); const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; @@ -119,33 +148,46 @@ export function toMarkdown( lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`); } lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`); - if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`); - if (project.connection) { - const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider; - lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`); - } lines.push(""); if (project.description) { lines.push("## Beschreibung", "", project.description, ""); } - const snapshot = project.snapshot; - if (snapshot) { - lines.push("## Stand des Repositories", ""); - lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, ""); + for (const repository of project.repositories) { + const titel = repoTitle(repository); + lines.push( + project.repositories.length === 1 + ? "## Repository" + : `## Repository: ${titel}`, + "", + ); + lines.push("| | |", "| --- | --- |"); - if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`); - if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`); - lines.push(`| Branches | ${snapshot.branchCount} |`); - lines.push(`| Tags | ${snapshot.tagCount} |`); - lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`); - lines.push(`| Offene Issues | ${snapshot.openIssues} |`); - if (snapshot.lastCommitSha) { - lines.push( - `| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` + - `${snapshot.lastCommitMessage ?? ""} |`, - ); + if (repository.repoUrl) lines.push(`| Adresse | ${repository.repoUrl} |`); + if (repository.connection) { + const label = + PROVIDER_LABEL[repository.connection.provider] ?? repository.connection.provider; + lines.push(`| Git-System | ${label} (${repository.connection.baseUrl}) |`); + } + + const snapshot = repository.snapshot; + if (snapshot) { + if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`); + if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`); + lines.push(`| Branches | ${snapshot.branchCount} |`); + lines.push(`| Tags | ${snapshot.tagCount} |`); + lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`); + lines.push(`| Offene Issues | ${snapshot.openIssues} |`); + if (snapshot.lastCommitSha) { + lines.push( + `| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` + + `${snapshot.lastCommitMessage ?? ""} |`, + ); + } + lines.push(`| Abgerufen | ${formatDateTime(snapshot.fetchedAt)} |`); + } else { + lines.push("| Stand | noch nie abgeglichen |"); } lines.push(""); } diff --git a/src/lib/git/meta.ts b/src/lib/git/meta.ts index 4fc2390..0fa0bed 100644 --- a/src/lib/git/meta.ts +++ b/src/lib/git/meta.ts @@ -29,6 +29,25 @@ export const PROVIDER_META: Record< }, }; +/** + * Wie ein Repository in Ueberschriften und Meldungen heisst. + * + * Steht hier und nicht bei den uebrigen Repository-Helfern, weil die + * Oberflaeche sie braucht: lib/repositories zieht Prisma und damit + * node:-Module nach, die in einer Client-Komponente nicht landen duerfen. + */ +export function repoTitle(repo: { + label?: string | null; + repoOwner?: string | null; + repoName?: string | null; + repoUrl?: string | null; +}): string { + if (repo.label) return repo.label; + if (repo.repoOwner && repo.repoName) return `${repo.repoOwner}/${repo.repoName}`; + if (repo.repoName) return repo.repoName; + return repo.repoUrl ?? "Repository"; +} + export function guessProvider(host: string): ProviderKind { const lower = host.toLowerCase(); if (lower.includes("github")) return "GITHUB"; diff --git a/src/lib/notify/index.ts b/src/lib/notify/index.ts index 267c390..14e25e2 100644 --- a/src/lib/notify/index.ts +++ b/src/lib/notify/index.ts @@ -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 { +export async function notifyAfterSync(repositoryId: string): Promise { 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 { // 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, diff --git a/src/lib/notify/pending.test.ts b/src/lib/notify/pending.test.ts index 0dec677..1161e14 100644 --- a/src/lib/notify/pending.test.ts +++ b/src/lib/notify/pending.test.ts @@ -1,24 +1,36 @@ import { describe, expect, it } from "vitest"; -import type { Project, RepoSnapshot } from "@prisma/client"; +import type { Project, RepoSnapshot, Repository } from "@prisma/client"; import { pendingEvents } from "./index"; /** * Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung * gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit * abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht. + * + * Der Merkstand liegt am Repository, nicht am Projekt – sonst verdeckte ein + * rotes Frontend die rote Pipeline des Backends. */ -function projekt(overrides: Partial = {}): Project { +const PROJEKT: Project = { + id: "p1", + name: "Testprojekt", + slug: "testprojekt", +} as Project; + +function repo(overrides: Partial = {}): Repository { return { - id: "p1", - name: "Testprojekt", - slug: "testprojekt", + id: "r1", + projectId: "p1", + label: null, + repoOwner: "wer", + repoName: "testprojekt", + repoUrl: null, syncError: null, notifiedSyncError: null, notifiedCiState: null, notifiedRelease: null, ...overrides, - } as Project; + } as Repository; } function snapshot(overrides: Partial = {}): RepoSnapshot { @@ -34,49 +46,49 @@ function snapshot(overrides: Partial = {}): RepoSnapshot { } as RepoSnapshot; } -const events = (p: Project, s: RepoSnapshot | null) => - pendingEvents(p, s).map((entry) => entry.event); +const events = (r: Repository, s: RepoSnapshot | null) => + pendingEvents(PROJEKT, r, s).map((entry) => entry.event); describe("Abgleichsfehler", () => { it("meldet einen neuen Fehler", () => { - expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]); + expect(events(repo({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]); }); it("meldet denselben Fehler kein zweites Mal", () => { - expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]); + expect(events(repo({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]); }); it("meldet, wenn sich der Fehler aendert", () => { expect( - events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null), + events(repo({ syncError: "401 Token", notifiedSyncError: "404" }), null), ).toEqual(["SYNC_FAILED"]); }); it("meldet nichts, wenn kein Fehler vorliegt", () => { - expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]); + expect(events(repo({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]); }); }); describe("CI", () => { it("meldet den Wechsel auf rot", () => { - expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]); + expect(events(repo(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]); }); it("meldet dauerhaft rote CI nur einmal", () => { expect( - events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })), + events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })), ).toEqual([]); }); it("meldet nicht, wenn die CI gruen ist", () => { expect( - events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })), + events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })), ).toEqual([]); }); it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => { expect( - events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })), + events(repo({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })), ).toEqual(["CI_FAILED"]); }); }); @@ -85,40 +97,63 @@ describe("Release", () => { it("schweigt beim allerersten Abgleich", () => { // Sonst waere jede bereits vorhandene Version eine "Neuigkeit". expect( - events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })), + events(repo({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })), ).toEqual([]); }); it("meldet eine neue Version", () => { expect( - events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })), + events(repo({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })), ).toEqual(["NEW_RELEASE"]); }); it("meldet dieselbe Version nicht erneut", () => { expect( - events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })), + events(repo({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })), ).toEqual([]); }); it("zieht den Releasenamen dem Tag vor", () => { const treffer = pendingEvents( - projekt({ notifiedRelease: "alt" }), + PROJEKT, + repo({ notifiedRelease: "alt" }), snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }), ); expect(treffer[0].message.title).toContain("Zweite Ausgabe"); }); }); +describe("Betreff bei mehreren Repositories", () => { + it("nennt das Repository nicht, wenn es ohnehin so heisst wie das Projekt", () => { + // "Testprojekt · wer/testprojekt: CI ist rot" liest niemand gern. + const treffer = pendingEvents( + PROJEKT, + repo({ repoOwner: "wer", repoName: "testprojekt" }), + snapshot({ ciState: "failure" }), + ); + expect(treffer[0].message.title).toContain("Testprojekt: CI ist rot"); + expect(treffer[0].message.title).not.toContain("wer/"); + }); + + it("nennt es, sobald es sich unterscheidet", () => { + const treffer = pendingEvents( + PROJEKT, + repo({ label: "Backend" }), + snapshot({ ciState: "failure" }), + ); + expect(treffer[0].message.title).toContain("Testprojekt · Backend"); + }); +}); + describe("mehrere Anlaesse gleichzeitig", () => { it("meldet Fehler und rote CI zusammen", () => { expect( - events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })), + events(repo({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })), ).toEqual(["SYNC_FAILED", "CI_FAILED"]); }); it("haengt Projektbezug und Tieflink an jede Nachricht", () => { - const treffer = pendingEvents(projekt({ syncError: "404" }), null); + const treffer = pendingEvents(PROJEKT, repo({ syncError: "404" }), null); expect(treffer[0].message.project?.slug).toBe("testprojekt"); expect(treffer[0].message.url).toContain("/projects/p1"); expect(treffer[0].message.priority).toBe("high"); diff --git a/src/lib/notify/stale.test.ts b/src/lib/notify/stale.test.ts index 088abcb..2433800 100644 --- a/src/lib/notify/stale.test.ts +++ b/src/lib/notify/stale.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { newestCommit } from "./stale"; import { daysSince, isStaleDue } from "./stale"; /** @@ -59,3 +60,26 @@ describe("daysSince", () => { expect(daysSince(jetzt, jetzt)).toBe(0); }); }); + +describe("newestCommit", () => { + it("nimmt den juengsten Commit ueber alle Repositories", () => { + // An einem Vorhaben wird gearbeitet, sobald an einem seiner Teile + // gearbeitet wird – ein ruhendes Backend macht es nicht stillstehend. + const juengster = newestCommit([ + { lastCommitAt: vorTagen(90) }, + { lastCommitAt: vorTagen(3) }, + { lastCommitAt: vorTagen(40) }, + ]); + expect(daysSince(juengster!, jetzt)).toBe(3); + }); + + it("uebergeht Repositories ohne Commit-Datum", () => { + const juengster = newestCommit([{ lastCommitAt: null }, { lastCommitAt: vorTagen(5) }]); + expect(daysSince(juengster!, jetzt)).toBe(5); + }); + + it("liefert null, wenn nirgends ein Commit bekannt ist", () => { + expect(newestCommit([])).toBeNull(); + expect(newestCommit([{ lastCommitAt: null }])).toBeNull(); + }); +}); diff --git a/src/lib/notify/stale.ts b/src/lib/notify/stale.ts index 1caf938..556a3bf 100644 --- a/src/lib/notify/stale.ts +++ b/src/lib/notify/stale.ts @@ -12,10 +12,13 @@ import { EVENT_META, type NotificationMessage } from "./types"; * passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens * einmal am Tag. * - * Massstab ist ausschliesslich der letzte Commit im verknuepften - * Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich + * Massstab ist ausschliesslich der letzte Commit in den verknuepften + * Repositories. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich * fasst es an und wuerde den Stillstand immer wieder zuruecksetzen. * Projekte ohne Repository bleiben folglich aussen vor. + * + * Bei mehreren Repositories zaehlt der juengste Commit. Ein Vorhaben steht + * nicht still, solange an einem seiner Teile gearbeitet wird. */ export interface StaleReport { @@ -48,6 +51,22 @@ export function daysSince(date: Date, now: Date = new Date()): number { return Math.floor((now.getTime() - date.getTime()) / 86_400_000); } +/** + * Der juengste Commit ueber alle Repositories hinweg. Massgeblich ist das + * regste Repo: an einem Vorhaben wird gearbeitet, sobald an irgendeinem + * seiner Teile gearbeitet wird. + */ +export function newestCommit( + snapshots: Array<{ lastCommitAt: Date | null }>, +): Date | null { + let juengster: Date | null = null; + for (const snapshot of snapshots) { + if (!snapshot.lastCommitAt) continue; + if (!juengster || snapshot.lastCommitAt > juengster) juengster = snapshot.lastCommitAt; + } + return juengster; +} + function message( project: Project, snapshot: RepoSnapshot | null, @@ -79,20 +98,34 @@ export async function runStaleCheck(thresholdDays: number): Promise // Fertige und archivierte Projekte duerfen ruhen. status: { notIn: ["DONE", "ARCHIVED"] }, ownerId: { not: null }, - snapshot: { isNot: null }, + repositories: { some: { snapshot: { isNot: null } } }, + }, + include: { + repositories: { + include: { snapshot: true }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, }, - include: { snapshot: true }, }); + const letzterCommit = new Map( + kandidaten.map((project) => [ + project.id, + newestCommit(project.repositories.map((repo) => repo.snapshot).filter((s) => s !== null)), + ]), + ); + const faellig = kandidaten.filter((project) => - isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff), + isStaleDue(project, letzterCommit.get(project.id) ?? null, cutoff), ); let zugestellt = 0; for (const project of faellig) { - const lastCommitAt = project.snapshot?.lastCommitAt; + const lastCommitAt = letzterCommit.get(project.id); if (!lastCommitAt || !project.ownerId) continue; + // Fuer den Link in der Meldung reicht das fuehrende Repository. + const snapshot = project.repositories[0]?.snapshot ?? null; const ziele = await prisma.notificationTarget.findMany({ where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } }, @@ -101,7 +134,7 @@ export async function runStaleCheck(thresholdDays: number): Promise for (const ziel of ziele) { const ergebnis = await deliver( ziel, - message(project, project.snapshot, daysSince(lastCommitAt, now)), + message(project, snapshot, daysSince(lastCommitAt, now)), ); zugestellt += 1; await prisma.notificationTarget.update({ diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 0135a08..f0a0715 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -46,9 +46,19 @@ export const connectionSafeSelect = Prisma.validator updatedAt: true, }); +/** + * Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall + * dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz. + */ +export const repositoryInclude = Prisma.validator()({ + repositories: { + include: { connection: { select: connectionSafeSelect }, snapshot: true }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, +}); + export const projectListInclude = Prisma.validator()({ - connection: { select: connectionSafeSelect }, - snapshot: true, + ...repositoryInclude, _count: { select: { notes: true, tasks: true } }, // Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der // Karte, ohne die Aufgaben selbst mitzuladen. @@ -56,8 +66,7 @@ export const projectListInclude = Prisma.validator()({ }); export const projectDetailInclude = Prisma.validator()({ - connection: { select: connectionSafeSelect }, - snapshot: true, + ...repositoryInclude, notes: { orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], }, diff --git a/src/lib/repositories.test.ts b/src/lib/repositories.test.ts new file mode 100644 index 0000000..4ba0397 --- /dev/null +++ b/src/lib/repositories.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { repoTitle } from "./git/meta"; +import { completeRepoInput, namesRepository } from "./repositories"; + +describe("completeRepoInput", () => { + it("leitet Owner und Name aus der URL ab", () => { + expect(completeRepoInput({ repoUrl: "https://github.com/vercel/swr" })).toMatchObject({ + repoOwner: "vercel", + repoName: "swr", + }); + }); + + it("laesst ausdruecklich gesetzte Werte in Ruhe", () => { + // Bei GitLab-Untergruppen weiss der Benutzer es besser als die Ableitung. + expect( + completeRepoInput({ + repoUrl: "https://gitlab.com/gruppe/untergruppe/dings", + repoOwner: "gruppe/untergruppe", + repoName: "dings", + }), + ).toMatchObject({ repoOwner: "gruppe/untergruppe", repoName: "dings" }); + }); + + it("kommt ohne URL und mit unbrauchbarer URL zurecht", () => { + expect(completeRepoInput({ repoOwner: "a", repoName: "b" }).repoUrl).toBeUndefined(); + expect(completeRepoInput({ repoUrl: "kein-link" }).repoOwner).toBeUndefined(); + }); +}); + +describe("namesRepository", () => { + it("erkennt, wann ueberhaupt ein Repo benannt wurde", () => { + expect(namesRepository({ repoUrl: "https://github.com/a/b" })).toBe(true); + expect(namesRepository({ repoOwner: "a", repoName: "b" })).toBe(true); + }); + + it("laesst Halbes nicht durchgehen", () => { + // Ein Owner ohne Namen ergibt kein Repository, sondern eine leere Zeile. + expect(namesRepository({})).toBe(false); + expect(namesRepository({ repoOwner: "a" })).toBe(false); + expect(namesRepository({ label: "Frontend" })).toBe(false); + }); +}); + +describe("repoTitle", () => { + it("zieht die Bezeichnung allem anderen vor", () => { + expect(repoTitle({ label: "Frontend", repoOwner: "a", repoName: "b" })).toBe("Frontend"); + }); + + it("faellt ueber owner/name auf die URL zurueck", () => { + expect(repoTitle({ repoOwner: "vercel", repoName: "swr" })).toBe("vercel/swr"); + expect(repoTitle({ repoName: "swr" })).toBe("swr"); + expect(repoTitle({ repoUrl: "https://example.com/x" })).toBe("https://example.com/x"); + expect(repoTitle({})).toBe("Repository"); + }); +}); diff --git a/src/lib/repositories.ts b/src/lib/repositories.ts new file mode 100644 index 0000000..d5ede1c --- /dev/null +++ b/src/lib/repositories.ts @@ -0,0 +1,116 @@ +import { prisma } from "@/lib/db"; +import { parseRepoUrl } from "@/lib/git"; +import { repoTitle } from "@/lib/git/meta"; + +// Der Titel steht in lib/git/meta, weil ihn auch Client-Komponenten +// brauchen – dieses Modul zieht Prisma nach. Hier weitergereicht, damit +// serverseitiger Code nur eine Anlaufstelle hat. +export { repoTitle }; + +/** + * Ein Projekt kann mehrere Repositories haben – Frontend und Backend etwa, + * aber ein Vorhaben. + * + * Das erste (position 0) ist das fuehrende. Wo nur eines dargestellt werden + * kann, ist es dieses: auf der Karte im Dashboard, im Kopf der Projektseite + * und in allem, was die alte Projekt-API weiterhin annimmt. + */ + +/** Angaben, aus denen ein Repository entsteht. */ +export interface RepoInput { + label?: string | null; + repoUrl?: string | null; + repoOwner?: string | null; + repoName?: string | null; + connectionId?: string | null; + autoSync?: boolean; +} + +/** + * Ergaenzt Owner und Name aus der URL, wenn sie nicht angegeben wurden. + * Ohne das muesste man beides von Hand eintragen, obwohl es in der URL steht. + */ +export function completeRepoInput(input: RepoInput): RepoInput { + if (!input.repoUrl || (input.repoOwner && input.repoName)) return input; + + const parsed = parseRepoUrl(input.repoUrl); + if (!parsed) return input; + + return { + ...input, + repoOwner: input.repoOwner ?? parsed.owner, + repoName: input.repoName ?? parsed.name, + }; +} + +/** Hat die Eingabe ueberhaupt ein Repository benannt? */ +export function namesRepository(input: RepoInput): boolean { + return Boolean(input.repoUrl || (input.repoOwner && input.repoName)); +} + +/** Prueft Existenz und Besitz in einem Zug – ueber das Projekt. */ +export async function ownedRepository(repositoryId: string, ownerId: string) { + return prisma.repository.findFirst({ + where: { id: repositoryId, project: { ownerId } }, + }); +} + +/** Naechste freie Position am Ende der Liste. */ +export async function nextRepoPosition(projectId: string): Promise { + const last = await prisma.repository.findFirst({ + where: { projectId }, + orderBy: { position: "desc" }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; +} + +/** + * Setzt die Angaben am fuehrenden Repository – und legt es an, falls es noch + * keines gibt. + * + * Damit bleibt die Projekt-API unveraendert bedienbar: wer beim Anlegen oder + * Bearbeiten eine Repo-URL mitgibt, bekommt weiterhin das erwartete Ergebnis, + * ohne von der neuen Tabelle wissen zu muessen. + * + * Eine geleerte URL entfernt das Repository nicht, sondern raeumt nur seine + * Felder – der gesammelte Stand haengt daran, und ein versehentlich + * geleertes Feld soll ihn nicht mitnehmen. + */ +export async function applyLeadingRepo(projectId: string, input: RepoInput): Promise { + const vollstaendig = completeRepoInput(input); + + const fuehrend = await prisma.repository.findFirst({ + where: { projectId }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + select: { id: true }, + }); + + if (!fuehrend) { + if (!namesRepository(vollstaendig)) return; + await prisma.repository.create({ + data: { + projectId, + label: vollstaendig.label ?? null, + repoUrl: vollstaendig.repoUrl ?? null, + repoOwner: vollstaendig.repoOwner ?? null, + repoName: vollstaendig.repoName ?? null, + connectionId: vollstaendig.connectionId ?? null, + autoSync: vollstaendig.autoSync ?? true, + position: 0, + }, + }); + return; + } + + const data: Record = {}; + if (vollstaendig.label !== undefined) data.label = vollstaendig.label; + if (vollstaendig.repoUrl !== undefined) data.repoUrl = vollstaendig.repoUrl; + if (vollstaendig.repoOwner !== undefined) data.repoOwner = vollstaendig.repoOwner; + if (vollstaendig.repoName !== undefined) data.repoName = vollstaendig.repoName; + if (vollstaendig.connectionId !== undefined) data.connectionId = vollstaendig.connectionId; + if (vollstaendig.autoSync !== undefined) data.autoSync = vollstaendig.autoSync; + + if (Object.keys(data).length === 0) return; + await prisma.repository.update({ where: { id: fuehrend.id }, data }); +} diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts index b4d4fb5..94e17eb 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -1,8 +1,9 @@ -import type { Project } from "@prisma/client"; +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 { @@ -12,21 +13,21 @@ export interface SyncResult { } /** - * 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. + * 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 syncProject(projectId: string): Promise { - const project = await prisma.project.findUnique({ - where: { id: projectId }, +export async function syncRepository(repositoryId: string): Promise { + const repository = await prisma.repository.findUnique({ + where: { id: repositoryId }, include: { connection: true }, }); - if (!project) { - return { ok: false, message: "Projekt nicht gefunden." }; + if (!repository) { + return { ok: false, message: "Repository nicht gefunden." }; } - const ref = resolveRepoRef(project); + const ref = resolveRepoRef(repository); if (!ref) { return { ok: false, @@ -35,11 +36,11 @@ export async function syncProject(projectId: string): Promise { } let client; - if (project.connection) { - client = clientForConnection(project.connection); + if (repository.connection) { + client = clientForConnection(repository.connection); } else { // Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL. - const parsed = project.repoUrl ? parseRepoUrl(project.repoUrl) : null; + const parsed = repository.repoUrl ? parseRepoUrl(repository.repoUrl) : null; if (!parsed) { return { ok: false, @@ -55,9 +56,9 @@ export async function syncProject(projectId: string): Promise { try { const data = await client.fetchRepo(ref); - await persistSnapshot(project.id, data); + await persistSnapshot(repository, data); // Nach dem Speichern, damit die Meldung den neuen Stand sieht. - await notifyAfterSync(project.id); + await notifyAfterSync(repository.id); return { ok: true, message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`, @@ -65,8 +66,8 @@ export async function syncProject(projectId: string): Promise { }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - await prisma.project.update({ - where: { id: project.id }, + await prisma.repository.update({ + where: { id: repository.id }, data: { syncError: message, lastSyncedAt: new Date(), @@ -76,25 +77,79 @@ export async function syncProject(projectId: string): Promise { }); // Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo // mit abgelaufenem Token schlaegt sonst stuendlich neu auf. - if (!project.syncError) { + if (!repository.syncError) { await recordActivity({ - projectId: project.id, + projectId: repository.projectId, kind: "SYNC_FAILED", - summary: `Abgleich fehlgeschlagen: ${shorten(message, 120)}`, + summary: `Abgleich von ${repoTitle(repository)} fehlgeschlagen: ${shorten(message, 100)}`, }); } - await notifyAfterSync(project.id); + await notifyAfterSync(repository.id); 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 }; +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 { + 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: [], + }; } - if (project.repoUrl) { - const parsed = parseRepoUrl(project.repoUrl); + + 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, +) { + 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; @@ -109,7 +164,7 @@ export function resolveRepoRef(project: Project) { * Commits hat, ist keine Neuigkeit. */ async function noteNewCommits( - projectId: string, + repository: Repository, vorher: string | null, data: RepoSnapshotData, ) { @@ -121,20 +176,21 @@ async function noteNewCommits( // Zahl als mit einer falschen. const index = data.commits.findIndex((commit) => commit.sha === vorher); const anzahl = index > 0 ? index : null; - const wo = data.defaultBranch ? ` auf ${data.defaultBranch}` : ""; + const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository); + const betreff = shorten(data.commits[0]?.message ?? "", 60); await recordActivity({ - projectId, + projectId: repository.projectId, kind: "REPO_COMMITS", summary: anzahl === null - ? `Neue Commits${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}` - : `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"}${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`, - meta: { from: vorher, to: neuester, count: anzahl }, + ? `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(projectId: string, data: RepoSnapshotData) { +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; @@ -179,18 +235,18 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) { // Vor dem Ueberschreiben lesen – danach ist der alte Stand weg. const vorher = await prisma.repoSnapshot.findUnique({ - where: { projectId }, + where: { repositoryId: repository.id }, select: { lastCommitSha: true }, }); await prisma.$transaction([ prisma.repoSnapshot.upsert({ - where: { projectId }, - create: { projectId, ...payload }, + where: { repositoryId: repository.id }, + create: { repositoryId: repository.id, ...payload }, update: payload, }), - prisma.project.update({ - where: { id: projectId }, + prisma.repository.update({ + where: { id: repository.id }, data: { lastSyncedAt: new Date(), syncError: null, @@ -199,7 +255,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) { 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(projectId, vorher?.lastCommitSha ?? null, data); + await noteNewCommits(repository, vorher?.lastCommitSha ?? null, data); } diff --git a/src/lib/sync/scheduler.ts b/src/lib/sync/scheduler.ts index 3283cd4..5e3c057 100644 --- a/src/lib/sync/scheduler.ts +++ b/src/lib/sync/scheduler.ts @@ -3,7 +3,7 @@ import { pruneActivities } from "@/lib/activity"; import { getAppSettings } from "@/lib/auth/settings"; import { prisma } from "@/lib/db"; import { runStaleCheck } from "@/lib/notify/stale"; -import { syncProject } from "@/lib/sync"; +import { syncRepository } from "@/lib/sync"; /** * Automatischer Abgleich mit den Git-Systemen. @@ -147,42 +147,45 @@ export async function runAutoSyncPass( const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize)); const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; - const where: Prisma.ProjectWhereInput = { + // Gezaehlt und begrenzt wird seit dem Umbau je Repository, nicht je + // Projekt: sonst zoege ein Projekt mit fuenf Repos fuenfmal so viele + // Anfragen nach sich wie sein Platz im Kontingent hergibt. + const where: Prisma.RepositoryWhereInput = { autoSync: true, - // Ohne Repository gibt es nichts abzugleichen. + // Ohne Angaben gibt es nichts abzugleichen. OR: [ { AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] }, { repoUrl: { not: null } }, ], ...(settings.autoSyncActiveOnly - ? { status: { notIn: ["DONE", "ARCHIVED"] } } + ? { project: { status: { notIn: ["DONE", "ARCHIVED"] } } } : {}), }; // Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer. - const candidates = await prisma.project.findMany({ + const candidates = await prisma.repository.findMany({ where, orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }], take: batchSize * 4, - select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true }, + select: { id: true, lastSyncedAt: true, syncFailureCount: true }, }); - const due = candidates.filter((project) => { - if (project.syncFailureCount === 0) return true; - if (!project.lastSyncedAt) return true; + const due = candidates.filter((repository) => { + if (repository.syncFailureCount === 0) return true; + if (!repository.lastSyncedAt) return true; // Nach jedem Fehlschlag doppelt so lange warten, gedeckelt. - const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS); + const steps = Math.min(repository.syncFailureCount, MAX_BACKOFF_STEPS); const wait = intervalMs * 2 ** steps; - const ready = Date.now() - project.lastSyncedAt.getTime() >= wait; + const ready = Date.now() - repository.lastSyncedAt.getTime() >= wait; if (!ready) skipped += 1; return ready; }); const batch = due.slice(0, batchSize); - for (const project of batch) { - const result = await syncProject(project.id); + for (const repository of batch) { + const result = await syncRepository(repository.id); if (result.ok) synced += 1; else failed += 1; if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 0f230e0..062f1ab 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -17,6 +17,12 @@ export const projectCreateSchema = z.object({ accent: z.enum(ACCENTS).default("violet"), tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]), favorite: z.boolean().default(false), + /** + * Die Git-Felder liegen seit dem Umbau am Repository, bleiben hier aber + * stehen: sie setzen das fuehrende Repository und legen es notfalls an. + * So kommt man mit einem Aufruf zu Projekt und Repo, und bestehende + * Aufrufer – auch der Import – muessen nichts wissen. + */ repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()), repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), @@ -29,6 +35,22 @@ export const projectCreateSchema = z.object({ export const projectUpdateSchema = projectCreateSchema.partial(); +// ── Repositories ───────────────────────────────────────────── + +export const repositoryCreateSchema = z.object({ + label: z.preprocess(emptyToNull, z.string().trim().max(80).nullable().optional()), + repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()), + repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), + repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), + connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), + autoSync: z.boolean().default(true), +}); + +export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({ + /** Zum Umsortieren; 0 ist das fuehrende. */ + position: z.coerce.number().int().min(0).max(50).optional(), +}); + // ── Aufgaben ───────────────────────────────────────────────── const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; diff --git a/src/types/index.ts b/src/types/index.ts index 41f589d..91d2068 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,7 @@ import type { Project, ProjectStatus, RepoSnapshot, + Repository, Task, TaskStatus, } from "@prisma/client"; @@ -33,12 +34,18 @@ export interface ConnectionSafe { } export interface ConnectionWithCount extends ConnectionSafe { - _count?: { projects: number }; + _count?: { repositories: number }; +} + +/** Ein Repository samt Verbindung und zuletzt geholtem Stand. */ +export interface RepositoryWithSnapshot extends Repository { + connection: ConnectionSafe | null; + snapshot: RepoSnapshot | null; } export interface ProjectListItem extends Project { - connection: ConnectionSafe | null; - snapshot: RepoSnapshot | null; + /** Das fuehrende zuerst – siehe repositoryInclude. */ + repositories: RepositoryWithSnapshot[]; _count: { notes: number; tasks: number }; /** Nur die erledigten – siehe projectListInclude. */ tasks: { id: string }[]; @@ -55,8 +62,7 @@ export interface ActivityEntry { } export interface ProjectDetail extends Project { - connection: ConnectionSafe | null; - snapshot: RepoSnapshot | null; + repositories: RepositoryWithSnapshot[]; notes: Note[]; tasks: Task[]; activities: ActivityEntry[]; @@ -83,6 +89,7 @@ export type { ProjectStatus, ReleaseInfo, RepoSnapshot, + Repository, TagInfo, Task, TaskStatus,