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,