From e1d435f10ac0d45ac880700a1b409e3d45daa361 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 15 Aug 2026 00:35:11 +0200 Subject: [PATCH] feat(tasks): Aufgabe als Git-Issue anlegen, Zustand wandert mit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Zuordnung stand im Schema laengst – sourceIssue, sourceUrl und sourceRepositoryId samt Eindeutigkeit je Repository, gefuellt von der Uebernahme aus Issues seit v0.9.0. Es fehlte die Gegenrichtung. Ein Knopf im Aufgabendialog legt das Issue an; Nummer und Adresse landen in denselben Feldern, der Dublettenschutz gilt also fuer beide Wege. In den Rumpf kommt ein Rueckverweis auf das Projekt. Ausdruecklich per Knopf und nie nebenbei: was in ein fremdes System geschrieben wird, soll jemand gewollt haben. Ist die Aufgabe erst verknuepft, wandert der Zustand allerdings mit – erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder. Fehlertolerant: ein nicht erreichbares Git-System darf das Abhaken nicht verhindern. Die Zustaende passen nicht aufeinander, und das bleibt so. Nebula kennt vier, ein Issue zwei; nach aussen geht nur die Grenze um ERLEDIGT. Ein Rundlauf verliert damit IN ARBEIT und BLOCKIERT. Labels waeren die Alternative, wuerden aber den Issue-Tracker mit Zustaenden fuellen, die dort niemand pflegt. Kein Widerspruch zu "keine eigenen Git-Operationen": Issues laufen ueber die HTTP-API der Provider. Dafuer gibt es jetzt sendJson neben getJson – durch dieselbe SSRF-Schleuse, aber ohne Umleitungen zu folgen. Ein 307 auf eine interne Adresse naehme den Rumpf und den Token im Header mit. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/api.spec.ts | 103 +++++++++++++++++++ src/app/api/tasks/[id]/issue/route.ts | 141 ++++++++++++++++++++++++++ src/app/api/tasks/[id]/route.ts | 18 ++++ src/lib/git/gitea.ts | 29 +++++- src/lib/git/github.ts | 29 +++++- src/lib/git/gitlab.ts | 27 ++++- src/lib/git/http.ts | 77 ++++++++++++++ src/lib/git/types.ts | 19 ++++ src/lib/tasks/issues.test.ts | 43 ++++++++ src/lib/tasks/issues.ts | 70 +++++++++++++ 10 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 src/app/api/tasks/[id]/issue/route.ts create mode 100644 src/lib/tasks/issues.test.ts create mode 100644 src/lib/tasks/issues.ts diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 44da962..3fa0060 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -279,6 +279,109 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => { expect(zumSchluss.repositories[0].repoName).toBe("swr"); }); +test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => { + await login(request, ADMIN); + + const projekt = ( + await ( + await request.post("/api/projects", { + data: { name: "Hakenprojekt", repoUrl: "https://github.com/vercel/swr" }, + }) + ).json() + ).project; + const repoId = projekt.repositories[0].id; + + // Ohne eingerichteten Webhook ist der Endpunkt stumm – und zwar mit 404, + // damit die ID nicht bestaetigt wird. + const ohne = await request.post(`/api/webhooks/${repoId}`, { + headers: { "Content-Type": "application/json" }, + data: { action: "closed", issue: { number: 1 } }, + }); + expect(ohne.status()).toBe(404); + + // Einrichten liefert das Geheimnis genau einmal zurueck. + const eingerichtet = await request.post(`/api/repositories/${repoId}/webhook`); + expect(eingerichtet.status()).toBe(200); + const { secret, url } = await eingerichtet.json(); + expect(typeof secret).toBe("string"); + expect(secret.length).toBeGreaterThanOrEqual(64); + expect(url).toContain(`/api/webhooks/${repoId}`); + + // Und danach nirgends mehr: weder im Projekt noch am Repository. + const geladen = await (await request.get(`/api/projects/${projekt.id}`)).json(); + expect(JSON.stringify(geladen)).not.toContain(secret); + expect(geladen.project.repositories[0].webhookSecret).toBeUndefined(); + + // Eine Aufgabe, die an einem Issue haengt – von Hand gesetzt gibt es + // nicht, also ueber die Uebernahme aus dem Snapshot. Stattdessen prueft + // dieser Test die Grenze selbst: ohne gueltige Signatur passiert nichts. + const rumpf = JSON.stringify({ action: "closed", issue: { number: 4242 } }); + + const falsch = await request.post(`/api/webhooks/${repoId}`, { + headers: { + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature-256": "sha256=00", + }, + data: rumpf, + }); + expect(falsch.status()).toBe(401); + + const gar_keine = await request.post(`/api/webhooks/${repoId}`, { + headers: { "Content-Type": "application/json", "X-GitHub-Event": "issues" }, + data: rumpf, + }); + expect(gar_keine.status()).toBe(401); + + // Mit richtiger Signatur wird der Aufruf angenommen. Zu Issue 4242 gibt + // es keine Aufgabe – erwartet ist also "angenommen, nichts zugeordnet". + const { createHmac } = await import("node:crypto"); + const signatur = createHmac("sha256", secret).update(rumpf, "utf8").digest("hex"); + const richtig = await request.post(`/api/webhooks/${repoId}`, { + headers: { + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature-256": `sha256=${signatur}`, + }, + data: rumpf, + }); + expect(richtig.status()).toBe(200); + const antwort = await richtig.json(); + expect(antwort).toMatchObject({ ok: true, handled: "issue", matched: false }); + + // Dieselbe Signatur zu einem anderen Rumpf gilt nicht. + const veraendert = await request.post(`/api/webhooks/${repoId}`, { + headers: { + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature-256": `sha256=${signatur}`, + }, + data: JSON.stringify({ action: "closed", issue: { number: 9999 } }), + }); + expect(veraendert.status()).toBe(401); + + // Fremde Konten koennen den Haken weder einrichten noch zuruecknehmen. + const fremd = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }); + await login(fremd, ZWEITER); + expect((await fremd.post(`/api/repositories/${repoId}/webhook`)).status()).toBe(404); + expect((await fremd.delete(`/api/repositories/${repoId}/webhook`)).status()).toBe(404); + await fremd.dispose(); + + // Zuruecknehmen schliesst den Endpunkt wieder. + expect((await request.delete(`/api/repositories/${repoId}/webhook`)).ok()).toBeTruthy(); + const danach = await request.post(`/api/webhooks/${repoId}`, { + headers: { + "Content-Type": "application/json", + "X-GitHub-Event": "issues", + "X-Hub-Signature-256": `sha256=${signatur}`, + }, + data: rumpf, + }); + expect(danach.status()).toBe(404); +}); + test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => { await login(request, ADMIN); diff --git a/src/app/api/tasks/[id]/issue/route.ts b/src/app/api/tasks/[id]/issue/route.ts new file mode 100644 index 0000000..57ef697 --- /dev/null +++ b/src/app/api/tasks/[id]/issue/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { recordActivity, shorten } from "@/lib/activity"; +import { requireUserApi } from "@/lib/auth/guard"; +import { APP_URL } from "@/lib/config"; +import { prisma } from "@/lib/db"; +import { GitApiError, clientForConnection } from "@/lib/git"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +const schema = z.object({ + /** Aus welchem Repository. Leer heisst: dem fuehrenden. */ + repositoryId: z.string().optional(), +}); + +/** + * Legt zu einer Aufgabe ein Issue im verknuepften Repository an. + * + * Der einzige Weg, auf dem Nebula von sich aus etwas in ein fremdes System + * schreibt – deshalb ausdruecklich angestossen und nie nebenbei. Umgekehrt + * gibt es die Uebernahme von Issues als Aufgaben schon laenger + * (tasks/from-issues); die Felder sourceIssue, sourceUrl und + * sourceRepositoryId sind dieselben, und die Eindeutigkeit je Repository + * verhindert, dass beide Wege dasselbe doppelt anlegen. + */ +export async function POST(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + + // Besitz haengt am Projekt – die Aufgabe selbst kennt keinen Besitzer. + const task = await prisma.task.findFirst({ + where: { id, project: { ownerId: auth.user.id } }, + select: { + id: true, + title: true, + description: true, + projectId: true, + sourceIssue: true, + sourceUrl: true, + project: { select: { name: true } }, + }, + }); + if (!task) return jsonError("Aufgabe nicht gefunden", 404); + + // Schon verknuepft: nicht noch einmal anlegen. Ein zweites Issue zur + // selben Aufgabe waere genau die Dublette, die from-issues vermeidet. + if (task.sourceIssue !== null) { + return NextResponse.json( + { + error: "Diese Aufgabe haengt bereits an einem Issue.", + issue: { number: task.sourceIssue, url: task.sourceUrl }, + }, + { status: 409 }, + ); + } + + 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: task.projectId, + ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}), + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + include: { connection: true }, + }); + + if (!repository) { + return jsonError("Das Projekt hat kein verknuepftes Repository.", 422); + } + if (!repository.connection) { + return jsonError( + "Das Repository haengt an keiner Git-Verbindung – ohne Token laesst sich nichts anlegen.", + 422, + ); + } + if (!repository.repoOwner || !repository.repoName) { + return jsonError("Am Repository fehlen Owner oder Name.", 422); + } + + const client = clientForConnection(repository.connection); + + // Rueckverweis in den Rumpf: wer das Issue im Git-System liest, soll + // wissen, wo es herkommt und wo der Rest der Notizen liegt. + const rumpf = [ + task.description?.trim() || null, + `_Angelegt aus [${task.project.name}](${APP_URL}/projects/${task.projectId}) in Nebula._`, + ] + .filter(Boolean) + .join("\n\n"); + + try { + const issue = await client.createIssue( + { owner: repository.repoOwner, name: repository.repoName }, + { title: task.title, body: rumpf }, + ); + + const aktualisiert = await prisma.task.update({ + where: { id: task.id }, + data: { + sourceIssue: issue.number, + sourceUrl: issue.url, + sourceRepositoryId: repository.id, + }, + }); + + await recordActivity({ + projectId: task.projectId, + userId: auth.user.id, + kind: "TASK_ADDED", + summary: `Issue #${issue.number} angelegt zu: ${shorten(task.title, 50)}`, + meta: { taskId: task.id, issue: issue.number, url: issue.url }, + }); + + return NextResponse.json({ task: aktualisiert, issue }, { status: 201 }); + } catch (error) { + // Der Aufruf ging nach draussen; die Meldung des Git-Systems ist + // hilfreicher als ein pauschales "hat nicht geklappt". + if (error instanceof GitApiError) { + return jsonError(`Issue konnte nicht angelegt werden: ${error.message}`, 502); + } + return jsonError("Issue konnte nicht angelegt werden.", 502); + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index b4fb21f..cc39de5 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -10,6 +10,7 @@ import { refreshDerivedProgress, spawnRecurrence, } from "@/lib/tasks"; +import { issueShouldBeOpen, mirrorStatusToIssue } from "@/lib/tasks/issues"; import { formatZodError, taskUpdateSchema } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -74,6 +75,23 @@ export async function PATCH(request: Request, { params }: Params) { await refreshDerivedProgress(existing.projectId); + /** + * Haengt die Aufgabe an einem Issue, wandert der Zustand mit: erledigt + * schliesst es, ein Zurueckziehen oeffnet es wieder. Nur die Grenze um + * ERLEDIGT geht nach draussen – mehr kennt ein Issue nicht. + * + * Fehlertolerant und ohne den Aufrufer warten zu lassen, was schiefgehen + * kann: ein nicht erreichbares Git-System darf das Abhaken nicht + * verhindern. Beim naechsten Wechsel wird es erneut versucht. + */ + if (update.status !== undefined) { + const vorher = issueShouldBeOpen(existing.status); + const nachher = issueShouldBeOpen(update.status as TaskStatus); + if (vorher !== nachher) { + await mirrorStatusToIssue(id, update.status as TaskStatus); + } + } + // Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder // ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu // erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat. diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts index 543efbd..7e27a3b 100644 --- a/src/lib/git/gitea.ts +++ b/src/lib/git/gitea.ts @@ -1,4 +1,4 @@ -import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; +import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http"; import { CALENDAR_PAGE_SIZE, calendarByPaging, @@ -12,6 +12,7 @@ import type { CiState, CommitInfo, ConnectionCheck, + CreatedIssue, GitClient, ProviderConfig, ReleaseInfo, @@ -44,6 +45,32 @@ export class GiteaClient implements GitClient { return headers; } + private repoBase(ref: RepoRef): string { + return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + } + + async createIssue( + ref: RepoRef, + issue: { title: string; body?: string | null }, + ): Promise { + const angelegt = await sendJson<{ number: number; html_url?: string }>( + `${this.repoBase(ref)}/issues`, + "POST", + { title: issue.title, body: issue.body ?? "" }, + { headers: this.headers() }, + ); + return { number: angelegt.number, url: angelegt.html_url ?? null }; + } + + async setIssueState(ref: RepoRef, number: number, open: boolean): Promise { + await sendJson( + `${this.repoBase(ref)}/issues/${number}`, + "PATCH", + { state: open ? "open" : "closed" }, + { headers: this.headers() }, + ); + } + async check(): Promise { try { const version = await getJsonSafe<{ version?: string }>( diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts index 7609779..2aa1545 100644 --- a/src/lib/git/github.ts +++ b/src/lib/git/github.ts @@ -1,4 +1,4 @@ -import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; +import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http"; import type { BranchInfo, CiInfo, @@ -6,6 +6,7 @@ import type { CommitCalendar, CommitInfo, ConnectionCheck, + CreatedIssue, GitClient, ProviderConfig, ReleaseInfo, @@ -43,6 +44,32 @@ export class GitHubClient implements GitClient { return headers; } + private repoBase(ref: RepoRef): string { + return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + } + + async createIssue( + ref: RepoRef, + issue: { title: string; body?: string | null }, + ): Promise { + const angelegt = await sendJson<{ number: number; html_url?: string }>( + `${this.repoBase(ref)}/issues`, + "POST", + { title: issue.title, body: issue.body ?? "" }, + { headers: this.headers() }, + ); + return { number: angelegt.number, url: angelegt.html_url ?? null }; + } + + async setIssueState(ref: RepoRef, number: number, open: boolean): Promise { + await sendJson( + `${this.repoBase(ref)}/issues/${number}`, + "PATCH", + { state: open ? "open" : "closed" }, + { headers: this.headers() }, + ); + } + async check(): Promise { try { if (!this.token) { diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts index fa8b5f7..05280bf 100644 --- a/src/lib/git/gitlab.ts +++ b/src/lib/git/gitlab.ts @@ -1,4 +1,4 @@ -import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; +import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http"; import { CALENDAR_PAGE_SIZE, calendarByPaging, @@ -12,6 +12,7 @@ import type { CiState, CommitInfo, ConnectionCheck, + CreatedIssue, GitClient, ProviderConfig, ReleaseInfo, @@ -45,6 +46,30 @@ export class GitLabClient implements GitClient { return headers; } + async createIssue( + ref: RepoRef, + issue: { title: string; body?: string | null }, + ): Promise { + // GitLab nennt es "iid" – die projektinterne Nummer, die auch in der + // Oberflaeche steht. "id" waere die instanzweite und damit die falsche. + const angelegt = await sendJson<{ iid: number; web_url?: string }>( + `${this.apiBase}/projects/${this.projectPath(ref)}/issues`, + "POST", + { title: issue.title, description: issue.body ?? "" }, + { headers: this.headers() }, + ); + return { number: angelegt.iid, url: angelegt.web_url ?? null }; + } + + async setIssueState(ref: RepoRef, number: number, open: boolean): Promise { + await sendJson( + `${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`, + "PUT", + { state_event: open ? "reopen" : "close" }, + { headers: this.headers() }, + ); + } + async check(): Promise { try { if (!this.token) { diff --git a/src/lib/git/http.ts b/src/lib/git/http.ts index 8a3a71b..72dc6a0 100644 --- a/src/lib/git/http.ts +++ b/src/lib/git/http.ts @@ -98,6 +98,83 @@ export async function getJson( } } +/** + * Schreibender Aufruf – POST, PATCH oder PUT. + * + * Bewusst dieselbe Schleuse wie getJson: die Basis-URL stammt aus einem + * Formular, und ohne die Pruefung waere ein schreibender Aufruf der weitaus + * unangenehmere Weg nach innen. Redirects werden hier **nicht** verfolgt. + * Ein 307 auf eine interne Adresse wuerde den Rumpf mitnehmen, und der + * enthaelt bei uns immer den Token im Header – lieber ein Fehler als eine + * Anfrage, die woanders landet. + */ +export async function sendJson( + url: string, + method: "POST" | "PATCH" | "PUT", + body: unknown, + options: FetchOptions = {}, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.timeoutMs ?? DEFAULT_TIMEOUT, + ); + + try { + await assertAllowedUrl(url); + + const response = await fetch(url, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "Nebula-Dashboard", + ...options.headers, + }, + body: JSON.stringify(body), + signal: controller.signal, + cache: "no-store", + redirect: "manual", + }); + + // Bei redirect: "manual" kommt der Umleitungsstatus durch, statt dass + // fetch ihm folgt. Das ist hier gewollt und ein Fehlerfall. + if (response.status >= 300 && response.status < 400) { + throw new GitApiError( + "Das Git-System hat umgeleitet. Schreibende Aufrufe folgen dem nicht – " + + "bitte die Basis-URL der Verbindung auf die endgueltige Adresse setzen.", + response.status, + url, + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url); + } + + const text = await response.text(); + if (!text) return null as T; + return JSON.parse(text) as T; + } catch (error) { + if (error instanceof GitApiError) throw error; + if (error instanceof BlockedHostError) { + throw new GitApiError(error.message, 403, url); + } + if (error instanceof Error && error.name === "AbortError") { + throw new GitApiError( + `Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`, + 408, + url, + ); + } + const reason = error instanceof Error ? error.message : String(error); + throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url); + } finally { + clearTimeout(timeout); + } +} + /** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */ export async function getJsonSafe( url: string, diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts index f95fdc9..6b296e1 100644 --- a/src/lib/git/types.ts +++ b/src/lib/git/types.ts @@ -167,10 +167,29 @@ export interface ProviderConfig { token: string | null; } +/** Ein neu angelegtes Issue, so weit wir es zurueckschreiben. */ +export interface CreatedIssue { + number: number; + url: string | null; +} + export interface GitClient { readonly provider: ProviderKind; /** Prueft Erreichbarkeit und Token-Gueltigkeit. */ check(): Promise; + /** + * Legt ein Issue an und liefert Nummer und Adresse zurueck. + * + * Der einzige schreibende Aufruf in dieser Schnittstelle. Er wird nie + * nebenbei ausgeloest, sondern immer von Hand angestossen – was nach + * aussen geht, soll jemand gewollt haben. + */ + createIssue(ref: RepoRef, issue: { title: string; body?: string | null }): Promise; + /** + * Schliesst ein Issue. Beim Zurueckziehen einer Aufgabe wird es wieder + * geoeffnet – daher der Schalter statt zweier Methoden. + */ + setIssueState(ref: RepoRef, number: number, open: boolean): Promise; /** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */ listRepos(query?: string): Promise; /** Vollstaendiger Stand eines Repositories. */ diff --git a/src/lib/tasks/issues.test.ts b/src/lib/tasks/issues.test.ts new file mode 100644 index 0000000..5baecbc --- /dev/null +++ b/src/lib/tasks/issues.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { issueShouldBeOpen, statusForIssueState } from "./issues"; + +describe("issueShouldBeOpen", () => { + it("haelt das Issue offen, solange die Aufgabe nicht erledigt ist", () => { + expect(issueShouldBeOpen("TODO")).toBe(true); + expect(issueShouldBeOpen("DOING")).toBe(true); + expect(issueShouldBeOpen("BLOCKED")).toBe(true); + }); + + it("schliesst es erst bei ERLEDIGT", () => { + expect(issueShouldBeOpen("DONE")).toBe(false); + }); +}); + +describe("statusForIssueState", () => { + it("macht aus einem geschlossenen Issue eine erledigte Aufgabe", () => { + expect(statusForIssueState(false, "TODO")).toBe("DONE"); + expect(statusForIssueState(false, "DOING")).toBe("DONE"); + }); + + it("holt eine erledigte Aufgabe zurueck nach OFFEN", () => { + expect(statusForIssueState(true, "DONE")).toBe("TODO"); + }); + + it("laesst den feineren Zustand stehen, wenn das Issue ohnehin offen ist", () => { + // Ein "opened"-Ereignis zu einer Aufgabe, die schon in Arbeit ist, darf + // sie nicht auf OFFEN zuruecksetzen – IN ARBEIT ist mehr Information. + expect(statusForIssueState(true, "DOING")).toBe("DOING"); + expect(statusForIssueState(true, "BLOCKED")).toBe("BLOCKED"); + expect(statusForIssueState(true, "TODO")).toBe("TODO"); + }); + + it("bildet den Rundlauf ab, so verlustbehaftet wie er ist", () => { + // IN ARBEIT geht nach draussen als "offen". Kommt es zurueck, bleibt es + // IN ARBEIT – der Zustand ueberlebt also, solange niemand das Issue + // schliesst. Nach dem Schliessen und Wiederoeffnen ist er weg, und das + // ist die bewusst hingenommene Grenze. + const hin = issueShouldBeOpen("DOING"); + expect(statusForIssueState(hin, "DOING")).toBe("DOING"); + expect(statusForIssueState(true, statusForIssueState(false, "DOING"))).toBe("TODO"); + }); +}); diff --git a/src/lib/tasks/issues.ts b/src/lib/tasks/issues.ts new file mode 100644 index 0000000..8d2e42f --- /dev/null +++ b/src/lib/tasks/issues.ts @@ -0,0 +1,70 @@ +import type { TaskStatus } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { clientForConnection } from "@/lib/git"; + +/** + * Die Bruecke zwischen Nebula-Aufgabe und Git-Issue. + * + * Die Zustaende passen nicht aufeinander und das bleibt so: Nebula kennt + * OFFEN, IN ARBEIT, BLOCKIERT und ERLEDIGT, ein Issue nur offen oder + * geschlossen. Nach aussen geht deshalb nur die Grenze zwischen "erledigt" + * und "nicht erledigt"; ein Rundlauf verliert IN ARBEIT und BLOCKIERT. + * + * Die Alternative waeren Labels wie "nebula:doing" – die wuerden den + * Issue-Tracker aber mit Zustaenden fuellen, die dort niemand pflegt. Die + * feine Abstufung bleibt in Nebula. + */ + +/** Ist der Aufgabenzustand einer, bei dem das Issue offen sein soll? */ +export function issueShouldBeOpen(status: TaskStatus): boolean { + return status !== "DONE"; +} + +/** Der Aufgabenzustand, der zu einem Issue-Zustand passt. */ +export function statusForIssueState(open: boolean, current: TaskStatus): TaskStatus { + if (open) { + // Ein wieder geoeffnetes Issue macht aus ERLEDIGT wieder OFFEN. Stand + // die Aufgabe ohnehin nicht auf ERLEDIGT, bleibt ihr feinerer Zustand + // stehen – IN ARBEIT ist mehr Information als OFFEN. + return current === "DONE" ? "TODO" : current; + } + return "DONE"; +} + +/** + * Traegt den Zustand einer Aufgabe an ihrem Issue nach. + * + * Bewusst fehlertolerant: schlaegt der Aufruf fehl, ist die Aufgabe in + * Nebula trotzdem umgezogen. Ein nicht erreichbares Git-System darf das + * Abhaken nicht verhindern – der naechste Wechsel versucht es erneut. + * + * Gibt zurueck, ob es geklappt hat; null heisst "nichts zu tun". + */ +export async function mirrorStatusToIssue( + taskId: string, + status: TaskStatus, +): Promise { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { sourceIssue: true, sourceRepositoryId: true }, + }); + if (!task?.sourceIssue || !task.sourceRepositoryId) return null; + + const repository = await prisma.repository.findUnique({ + where: { id: task.sourceRepositoryId }, + include: { connection: true }, + }); + if (!repository?.connection || !repository.repoOwner || !repository.repoName) return null; + + try { + const client = clientForConnection(repository.connection); + await client.setIssueState( + { owner: repository.repoOwner, name: repository.repoName }, + task.sourceIssue, + issueShouldBeOpen(status), + ); + return true; + } catch { + return false; + } +}