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:
@@ -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,
|
||||
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.
|
||||
|
||||
+28
-1
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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