feat(tasks): Aufgabe als Git-Issue anlegen, Zustand wandert mit
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) <noreply@anthropic.com>
This commit is contained in:
+103
@@ -279,6 +279,109 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
|
|||||||
expect(zumSchluss.repositories[0].repoName).toBe("swr");
|
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 }) => {
|
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
|
||||||
await login(request, ADMIN);
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
refreshDerivedProgress,
|
refreshDerivedProgress,
|
||||||
spawnRecurrence,
|
spawnRecurrence,
|
||||||
} from "@/lib/tasks";
|
} from "@/lib/tasks";
|
||||||
|
import { issueShouldBeOpen, mirrorStatusToIssue } from "@/lib/tasks/issues";
|
||||||
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
|
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -74,6 +75,23 @@ export async function PATCH(request: Request, { params }: Params) {
|
|||||||
|
|
||||||
await refreshDerivedProgress(existing.projectId);
|
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
|
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
|
||||||
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
|
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
|
||||||
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
|
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
|
||||||
|
|||||||
+28
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
||||||
import {
|
import {
|
||||||
CALENDAR_PAGE_SIZE,
|
CALENDAR_PAGE_SIZE,
|
||||||
calendarByPaging,
|
calendarByPaging,
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
CiState,
|
CiState,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionCheck,
|
ConnectionCheck,
|
||||||
|
CreatedIssue,
|
||||||
GitClient,
|
GitClient,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
@@ -44,6 +45,32 @@ export class GiteaClient implements GitClient {
|
|||||||
return headers;
|
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<CreatedIssue> {
|
||||||
|
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<void> {
|
||||||
|
await sendJson(
|
||||||
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
|
"PATCH",
|
||||||
|
{ state: open ? "open" : "closed" },
|
||||||
|
{ headers: this.headers() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
const version = await getJsonSafe<{ version?: string }>(
|
const version = await getJsonSafe<{ version?: string }>(
|
||||||
|
|||||||
+28
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CiInfo,
|
CiInfo,
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CommitCalendar,
|
CommitCalendar,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionCheck,
|
ConnectionCheck,
|
||||||
|
CreatedIssue,
|
||||||
GitClient,
|
GitClient,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
@@ -43,6 +44,32 @@ export class GitHubClient implements GitClient {
|
|||||||
return headers;
|
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<CreatedIssue> {
|
||||||
|
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<void> {
|
||||||
|
await sendJson(
|
||||||
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
|
"PATCH",
|
||||||
|
{ state: open ? "open" : "closed" },
|
||||||
|
{ headers: this.headers() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
|
|||||||
+26
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
|
||||||
import {
|
import {
|
||||||
CALENDAR_PAGE_SIZE,
|
CALENDAR_PAGE_SIZE,
|
||||||
calendarByPaging,
|
calendarByPaging,
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
CiState,
|
CiState,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionCheck,
|
ConnectionCheck,
|
||||||
|
CreatedIssue,
|
||||||
GitClient,
|
GitClient,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
@@ -45,6 +46,30 @@ export class GitLabClient implements GitClient {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createIssue(
|
||||||
|
ref: RepoRef,
|
||||||
|
issue: { title: string; body?: string | null },
|
||||||
|
): Promise<CreatedIssue> {
|
||||||
|
// 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<void> {
|
||||||
|
await sendJson(
|
||||||
|
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||||
|
"PUT",
|
||||||
|
{ state_event: open ? "reopen" : "close" },
|
||||||
|
{ headers: this.headers() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
|
|||||||
@@ -98,6 +98,83 @@ export async function getJson<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(
|
||||||
|
url: string,
|
||||||
|
method: "POST" | "PATCH" | "PUT",
|
||||||
|
body: unknown,
|
||||||
|
options: FetchOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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. */
|
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
|
||||||
export async function getJsonSafe<T>(
|
export async function getJsonSafe<T>(
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -167,10 +167,29 @@ export interface ProviderConfig {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein neu angelegtes Issue, so weit wir es zurueckschreiben. */
|
||||||
|
export interface CreatedIssue {
|
||||||
|
number: number;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitClient {
|
export interface GitClient {
|
||||||
readonly provider: ProviderKind;
|
readonly provider: ProviderKind;
|
||||||
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */
|
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */
|
||||||
check(): Promise<ConnectionCheck>;
|
check(): Promise<ConnectionCheck>;
|
||||||
|
/**
|
||||||
|
* 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<CreatedIssue>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
|
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
|
||||||
listRepos(query?: string): Promise<RepoListItem[]>;
|
listRepos(query?: string): Promise<RepoListItem[]>;
|
||||||
/** Vollstaendiger Stand eines Repositories. */
|
/** Vollstaendiger Stand eines Repositories. */
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<boolean | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user