feat(tasks): offene Git-Issues als Aufgaben uebernehmen

Ein Klick im Git-Panel macht aus den offenen Issues Aufgaben - mit Titel,
Labels und einem Link zurueck ins Git-System.

Grundlage ist der zuletzt geholte Snapshot. Hier wird bewusst nichts
nachgeladen: ein Klick soll nicht ungefragt fremde APIs ansprechen und
womoeglich in ein Rate-Limit laufen. Wer den neuesten Stand will, gleicht
vorher ab; ohne Snapshot sagt der Server das statt still nichts zu tun.

Die Issue-Nummer bleibt an der Aufgabe und ist je Projekt eindeutig. Ein
zweiter Klick legt deshalb keine Dubletten an, auch wenn die Aufgabe
inzwischen verschoben oder umbenannt wurde. NULL gilt in Postgres als
verschieden, von Hand angelegte Aufgaben stoeren die Eindeutigkeit also
nicht.

Nachgewiesen an vercel/swr: 15 offene Issues uebernommen, beim zweiten
Aufruf 0 angelegt und 15 uebersprungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:36:44 +02:00
co-authored by Claude Opus 5
parent 3bad4ace10
commit 75460c8cf4
4 changed files with 174 additions and 1 deletions
@@ -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");
+8
View File
@@ -347,9 +347,17 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime? 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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]) @@index([projectId, status, position])
} }
@@ -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<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, 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,
});
}
+53 -1
View File
@@ -12,6 +12,7 @@ import {
GitMerge, GitMerge,
GitPullRequest, GitPullRequest,
HardDrive, HardDrive,
ListPlus,
Loader2, Loader2,
Lock, Lock,
Package, Package,
@@ -48,8 +49,36 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<TabKey>("pulls"); const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); 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 snapshot = project.snapshot;
const branches = asArray<BranchInfo>(snapshot?.branches); const branches = asArray<BranchInfo>(snapshot?.branches);
const tags = asArray<TagInfo>(snapshot?.tags); const tags = asArray<TagInfo>(snapshot?.tags);
@@ -329,7 +358,30 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
/> />
) : null} ) : null}
{tab === "issues" ? ( {tab === "issues" ? (
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} /> <>
{issues.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
<p className="text-[11px] text-slate-500">
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden
uebersprungen.
</p>
<button
type="button"
className="btn btn-ghost px-2.5 py-1 text-[11px]"
onClick={importIssues}
disabled={importing}
>
{importing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ListPlus className="h-3 w-3" />
)}
Als Aufgaben uebernehmen
</button>
</div>
) : null}
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
</>
) : null} ) : null}
{tab === "branches" ? <BranchList branches={branches} /> : null} {tab === "branches" ? <BranchList branches={branches} /> : null}
{tab === "tags" ? <TagList tags={tags} /> : null} {tab === "tags" ? <TagList tags={tags} /> : null}