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:
2026-08-15 00:35:11 +02:00
co-authored by Claude Opus 5
parent 6a7365946b
commit e1d435f10a
10 changed files with 553 additions and 3 deletions
+28 -1
View File
@@ -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<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> {
try {
const version = await getJsonSafe<{ version?: string }>(
+28 -1
View File
@@ -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<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> {
try {
if (!this.token) {
+26 -1
View File
@@ -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<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> {
try {
if (!this.token) {
+77
View File
@@ -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. */
export async function getJsonSafe<T>(
url: string,
+19
View File
@@ -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<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). */
listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */
+43
View File
@@ -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");
});
});
+70
View File
@@ -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;
}
}