Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene Tabelle, der Snapshot haengt jetzt am Repository. Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem* erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt. Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem seiner Teile gearbeitet wird. Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander - ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das, worauf Rate-Limits reagieren. Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere Aufgaben ohne Herkunft bleiben erlaubt. Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo, und der Import muss von der neuen Tabelle nichts wissen. Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt lesbar: dort stand genau ein Repository flach am Projekt. Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht. Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung, Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo bekommen keine leere Zeile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
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";
|
||
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
|
||
import { asArray, type TicketInfo } from "@/types";
|
||
import { formatZodError } from "@/lib/validation";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
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(),
|
||
});
|
||
|
||
/**
|
||
* Uebernimmt offene Issues des verknuepften Repositories als Aufgaben.
|
||
*
|
||
* Grundlage ist der zuletzt geholte Snapshot – hier wird nichts nachgeladen,
|
||
* damit ein Klick nicht ungefragt fremde APIs anspricht. Wer den neuesten
|
||
* Stand will, gleicht vorher ab.
|
||
*
|
||
* Bereits uebernommene Issues werden uebersprungen: die Nummer steht an der
|
||
* Aufgabe und ist je Projekt eindeutig. Ein zweiter Aufruf legt also keine
|
||
* Dubletten an, auch wenn die Aufgabe inzwischen verschoben oder umbenannt
|
||
* wurde.
|
||
*/
|
||
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 = schema.safeParse(body.data ?? {});
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||
{ status: 422 },
|
||
);
|
||
}
|
||
|
||
// 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.",
|
||
409,
|
||
);
|
||
}
|
||
|
||
const alle = asArray<TicketInfo>(snapshot.issues);
|
||
const gewuenscht = parsed.data.numbers;
|
||
const auswahl = gewuenscht?.length
|
||
? alle.filter((issue) => gewuenscht.includes(issue.number))
|
||
: alle;
|
||
|
||
if (auswahl.length === 0) {
|
||
return NextResponse.json({ created: 0, skipped: 0, tasks: [] });
|
||
}
|
||
|
||
const vorhanden = await prisma.task.findMany({
|
||
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));
|
||
|
||
const neu = auswahl.filter((issue) => !bekannt.has(issue.number));
|
||
let position = await nextPosition(id, "TODO");
|
||
|
||
const angelegt = [];
|
||
for (const issue of neu) {
|
||
angelegt.push(
|
||
await prisma.task.create({
|
||
data: {
|
||
projectId: id,
|
||
title: issue.title.slice(0, 200),
|
||
// Der Link ist das Wichtigste – der Text steht im Git-System.
|
||
description: issue.url ? `[Issue #${issue.number}](${issue.url})` : null,
|
||
status: "TODO",
|
||
position: position++,
|
||
labels: issue.labels.slice(0, 8),
|
||
sourceIssue: issue.number,
|
||
sourceUrl: issue.url,
|
||
sourceRepositoryId: repository.id,
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (angelegt.length > 0) {
|
||
await refreshDerivedProgress(id);
|
||
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
|
||
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
|
||
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
|
||
await recordActivity({
|
||
projectId: id,
|
||
userId: auth.user.id,
|
||
kind: "TASKS_IMPORTED",
|
||
summary:
|
||
angelegt.length === 1
|
||
? `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),
|
||
},
|
||
});
|
||
}
|
||
|
||
return NextResponse.json({
|
||
created: angelegt.length,
|
||
skipped: auswahl.length - angelegt.length,
|
||
tasks: angelegt,
|
||
});
|
||
}
|