diff --git a/prisma/migrations/20260726180000_task_source_issue/migration.sql b/prisma/migrations/20260726180000_task_source_issue/migration.sql new file mode 100644 index 0000000..74b0c62 --- /dev/null +++ b/prisma/migrations/20260726180000_task_source_issue/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "sourceIssue" INTEGER, +ADD COLUMN "sourceUrl" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Task_projectId_sourceIssue_key" ON "Task"("projectId", "sourceIssue"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9841af8..6d73c8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -347,9 +347,17 @@ 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. + sourceIssue Int? + sourceUrl 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]) @@index([projectId, status, position]) } diff --git a/src/app/api/projects/[id]/tasks/from-issues/route.ts b/src/app/api/projects/[id]/tasks/from-issues/route.ts new file mode 100644 index 0000000..f8a8da2 --- /dev/null +++ b/src/app/api/projects/[id]/tasks/from-issues/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +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(), +}); + +/** + * 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 }, + ); + } + + const snapshot = await prisma.repoSnapshot.findUnique({ where: { projectId: id } }); + if (!snapshot) { + return jsonError( + "Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.", + 409, + ); + } + + const alle = asArray(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, 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, + }, + }), + ); + } + + if (angelegt.length > 0) { + await refreshDerivedProgress(id); + await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } }); + } + + return NextResponse.json({ + created: angelegt.length, + skipped: auswahl.length - angelegt.length, + tasks: angelegt, + }); +} diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index e20e6cb..8b9f782 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -12,6 +12,7 @@ import { GitMerge, GitPullRequest, HardDrive, + ListPlus, Loader2, Lock, Package, @@ -48,8 +49,36 @@ export function GitPanel({ project }: { project: ProjectDetail }) { const router = useRouter(); const [tab, setTab] = useState("pulls"); const [syncing, setSyncing] = useState(false); + const [importing, setImporting] = useState(false); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ + async function importIssues() { + setImporting(true); + setMessage(null); + try { + const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); + setMessage({ + ok: true, + text: + body.created === 0 + ? `Nichts Neues – alle ${body.skipped} Issues sind bereits als Aufgabe vorhanden.` + : `${body.created} Aufgabe(n) angelegt${body.skipped ? `, ${body.skipped} uebersprungen` : ""}.`, + }); + router.refresh(); + } catch (caught) { + setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setImporting(false); + } + } + const snapshot = project.snapshot; const branches = asArray(snapshot?.branches); const tags = asArray(snapshot?.tags); @@ -329,7 +358,30 @@ export function GitPanel({ project }: { project: ProjectDetail }) { /> ) : null} {tab === "issues" ? ( - + <> + {issues.length > 0 ? ( +
+

+ Aus offenen Issues Aufgaben machen. Bereits uebernommene werden + uebersprungen. +

+ +
+ ) : null} + + ) : null} {tab === "branches" ? : null} {tab === "tags" ? : null}