diff --git a/FEATURES.md b/FEATURES.md index 3647dce..2478792 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -529,7 +529,32 @@ soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledig das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern. -**Git → Nebula** über Webhooks, siehe unten. +**Git → Nebula** auf zwei Wegen, die sich ergänzen: + +- **Webhook** — sofort, sobald das Issue geschlossen wird. Setzt voraus, dass die Instanz + von außen erreichbar ist und der Haken eingetragen wurde (siehe 4.7). +- **Beim Abgleich** — verzögert, aber verlässlich. Der Abgleich fragt den Zustand + verknüpfter Issues nach und hakt ab, was in Git geschlossen ist. Damit funktioniert der + Rückweg auch ohne Webhook, ohne öffentliche Erreichbarkeit und selbst dann, wenn ein + Haken einmal ausfällt. + + Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen führt, + werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht — die Liste der offenen + Issues ist gedeckelt, aus dem Fehlen einer Nummer folgt also nichts. Pro Durchgang + fragt der Abgleich höchstens 25 Issues nach. + + **Der Abgleich hakt nur ab, er nimmt nie zurück.** Er sieht Zustände, keine Ereignisse: + ein offenes Issue kann auch bedeuten, dass das Schließen von Nebula aus fehlgeschlagen + ist. Ein symmetrischer Abgleich würde dann eine bewusst erledigte Aufgabe wieder + aufmachen. Das Zurücknehmen bleibt dem Webhook vorbehalten, der ein echtes „reopened“ + sieht. + + Ein gelöschtes Issue ist kein erledigtes — die Aufgabe bleibt stehen. + +In beiden Fällen bewirkt ein geschlossenes Issue dasselbe wie ein Haken in der +Oberfläche: bei einer wiederkehrenden Aufgabe entsteht die nächste Fassung, und der +Vorgang landet im Projektverlauf (ohne Benutzer — das hat nicht jemand in Nebula getan, +sondern das Git-System gemeldet). **Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt `Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 161afa6..de86047 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -1,4 +1,13 @@ import { expect, test, type APIRequestContext } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; + +/** + * Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in + * dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet. + */ +const DATABASE_URL = + process.env.E2E_DATABASE_URL ?? + "postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public"; /** * Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware, @@ -325,6 +334,79 @@ test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, pla await request.delete(`/api/admin/users/${id}`); }); +test("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => { + await login(request, ADMIN); + + // Ein oeffentliches Repository mit sicher geschlossenen Issues. Der + // Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig. + const projekt = ( + await ( + await request.post("/api/projects", { + data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" }, + }) + ).json() + ).project; + const repoId = projekt.repositories[0].id; + + const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, { + data: { title: "Haengt an einem geschlossenen Issue" }, + }); + const taskId = (await angelegt.json()).task.id; + + /** + * Die Verknuepfung direkt in der Datenbank setzen. + * + * Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als + * Issue anlegen" wuerde ein neues Issue in einem fremden Repository + * erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im + * Produktionscode – dafuer greift der Test lieber selbst zur Datenbank. + * + * Issue 1 von vercel/swr ist seit Jahren geschlossen. + */ + const prisma = new PrismaClient({ + datasources: { db: { url: DATABASE_URL } }, + }); + try { + await prisma.task.update({ + where: { id: taskId }, + data: { sourceRepositoryId: repoId, sourceIssue: 1 }, + }); + } finally { + await prisma.$disconnect(); + } + + const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json(); + const alsAngelegt = vorher.project.tasks.find( + (t: { id: string }) => t.id === taskId, + ); + expect(alsAngelegt.sourceIssue).toBe(1); + expect(alsAngelegt.status).toBe("TODO"); + + // Abgleich anstossen – er fragt den Zustand des Issues nach. + const sync = await request.post(`/api/repositories/${repoId}/sync`); + const syncBody = await sync.json(); + + /** + * Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht + * erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt + * rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen. + */ + if (!sync.ok()) { + test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`); + return; + } + + const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json(); + const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId); + expect(abgehakt.status).toBe("DONE"); + expect(abgehakt.doneAt).not.toBeNull(); + + // Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt, + // ohne dass erneut nachgefragt wird. + const nochmal = await request.post(`/api/repositories/${repoId}/sync`); + expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/); +}); + test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => { await login(request, ADMIN); diff --git a/package.json b/package.json index f7649e4..fd6624d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": { diff --git a/src/app/api/webhooks/[repositoryId]/route.ts b/src/app/api/webhooks/[repositoryId]/route.ts index 5fa295c..79e2c0f 100644 --- a/src/app/api/webhooks/[repositoryId]/route.ts +++ b/src/app/api/webhooks/[repositoryId]/route.ts @@ -3,7 +3,7 @@ import { tryDecryptSecret } from "@/lib/crypto"; import { prisma } from "@/lib/db"; import { rateLimit } from "@/lib/security/rateLimit"; import { syncRepository } from "@/lib/sync"; -import { statusForIssueState } from "@/lib/tasks/issues"; +import { completeTaskFromIssue, statusForIssueState } from "@/lib/tasks/issues"; import { refreshDerivedProgress } from "@/lib/tasks"; import { parseEvent, verifySignature } from "@/lib/webhooks/verify"; @@ -118,13 +118,19 @@ export async function POST(request: Request, { params }: Params) { } const ziel = statusForIssueState(ereignis.open, task.status); - if (ziel !== task.status) { + + if (ziel === "DONE" && task.status !== "DONE") { + // Ueber die gemeinsame Funktion, damit ein geschlossenes Issue + // dasselbe bewirkt wie ein Haken in der Oberflaeche: naechste Fassung + // einer wiederkehrenden Aufgabe und ein Eintrag im Verlauf. + await completeTaskFromIssue(task.id, "webhook"); + } else if (ziel !== task.status) { + // Wieder geoeffnet. Das darf nur der Webhook, weil hier ein echtes + // "reopened" vorliegt – der Abgleich sieht nur Zustaende und wuerde + // sonst eine bewusst erledigte Aufgabe aufmachen. await prisma.task.update({ where: { id: task.id }, - data: { - status: ziel, - doneAt: ziel === "DONE" ? new Date() : null, - }, + data: { status: ziel, doneAt: null }, }); await refreshDerivedProgress(task.projectId); } diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 71ccdce..031fd55 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -41,6 +41,27 @@ export const KIND_META: Record }; export const CHANGELOG: Release[] = [ + { + version: "1.9.0", + date: "2026-08-17", + headline: "Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab.", + changes: [ + { + kind: "neu", + text: + "Der Abgleich prüft jetzt den Zustand verknüpfter Issues: was in Git geschlossen " + + "wurde, wird in Nebula abgehakt. Bisher konnte das nur der Webhook — war keiner " + + "eingerichtet, blieb der Rückweg ganz aus.", + }, + { + kind: "besser", + text: + "Ein über Git geschlossenes Issue bewirkt jetzt dasselbe wie ein Haken in der " + + "Oberfläche: bei wiederkehrenden Aufgaben entsteht die nächste Fassung, und der " + + "Vorgang steht im Projektverlauf.", + }, + ], + }, { version: "1.8.0", date: "2026-08-15", diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts index 7e27a3b..7fb9b94 100644 --- a/src/lib/git/gitea.ts +++ b/src/lib/git/gitea.ts @@ -71,6 +71,15 @@ export class GiteaClient implements GitClient { ); } + async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { + const issue = await getJson<{ state?: string } | null>( + `${this.repoBase(ref)}/issues/${number}`, + { headers: this.headers(), allow404: true }, + ); + if (!issue?.state) return null; + return issue.state === "closed" ? "closed" : "open"; + } + 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 2aa1545..93857dc 100644 --- a/src/lib/git/github.ts +++ b/src/lib/git/github.ts @@ -70,6 +70,15 @@ export class GitHubClient implements GitClient { ); } + async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { + const issue = await getJson<{ state?: string } | null>( + `${this.repoBase(ref)}/issues/${number}`, + { headers: this.headers(), allow404: true }, + ); + if (!issue?.state) return null; + return issue.state === "closed" ? "closed" : "open"; + } + async check(): Promise { try { if (!this.token) { diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts index 05280bf..5928ca1 100644 --- a/src/lib/git/gitlab.ts +++ b/src/lib/git/gitlab.ts @@ -70,6 +70,16 @@ export class GitLabClient implements GitClient { ); } + async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { + const issue = await getJson<{ state?: string } | null>( + `${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`, + { headers: this.headers(), allow404: true }, + ); + if (!issue?.state) return null; + // GitLab nennt den offenen Zustand "opened". + return issue.state === "closed" ? "closed" : "open"; + } + async check(): Promise { try { if (!this.token) { diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts index 6b296e1..6f22bdc 100644 --- a/src/lib/git/types.ts +++ b/src/lib/git/types.ts @@ -190,6 +190,17 @@ export interface GitClient { * geoeffnet – daher der Schalter statt zweier Methoden. */ setIssueState(ref: RepoRef, number: number, open: boolean): Promise; + /** + * Liest den Zustand eines einzelnen Issues. + * + * Gebraucht fuer den Abgleich: der Snapshot fuehrt nur die *offenen* + * Issues und ist ausserdem gedeckelt – aus dem Fehlen einer Nummer laesst + * sich deshalb nicht schliessen, dass sie geschlossen wurde. Sie koennte + * ebenso geloescht oder einfach jenseits des Deckels sein. + * + * null heisst: gibt es nicht (mehr). + */ + getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null>; /** 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/sync/index.ts b/src/lib/sync/index.ts index 1129262..bf07851 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -5,6 +5,7 @@ import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; import type { GitClient } from "@/lib/git/types"; import { notifyAfterSync } from "@/lib/notify"; import { repoTitle } from "@/lib/repositories"; +import { reconcileLinkedIssues } from "@/lib/tasks/issues"; import type { RepoSnapshotData } from "@/lib/git/types"; export interface SyncResult { @@ -62,11 +63,31 @@ export async function syncRepository(repositoryId: string): Promise // README hineingeschrieben werden koennen. await maybeRefreshCalendar(repository.id, client, ref); await maybeRefreshReadme(repository.id, client, ref); + + /** + * Verknuepfte Aufgaben nachziehen: was in Git geschlossen wurde, wird + * hier abgehakt. Der Webhook macht das sofort – ist keiner eingerichtet + * oder faellt er aus, greift der Abgleich. + * + * Die offenen Issues aus dem gerade geholten Snapshot gehen mit hinein: + * fuer jede Nummer, die dort steht, ist die Nachfrage gespart. + */ + const abgehakt = await reconcileLinkedIssues( + repository.id, + client, + ref, + data.issues.map((issue) => issue.number), + ); + // Nach dem Speichern, damit die Meldung den neuen Stand sieht. await notifyAfterSync(repository.id); return { ok: true, - message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`, + message: + `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` + + (abgehakt > 0 + ? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.` + : ""), fetchedAt: new Date().toISOString(), }; } catch (error) { diff --git a/src/lib/tasks/issues.ts b/src/lib/tasks/issues.ts index 8d2e42f..c963be3 100644 --- a/src/lib/tasks/issues.ts +++ b/src/lib/tasks/issues.ts @@ -1,6 +1,9 @@ import type { TaskStatus } from "@prisma/client"; +import { recordActivity, shorten } from "@/lib/activity"; import { prisma } from "@/lib/db"; import { clientForConnection } from "@/lib/git"; +import type { GitClient, RepoRef } from "@/lib/git/types"; +import { nextPosition, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks"; /** * Die Bruecke zwischen Nebula-Aufgabe und Git-Issue. @@ -68,3 +71,122 @@ export async function mirrorStatusToIssue( return false; } } + +/** + * Hakt eine Aufgabe ab, weil ihr Issue geschlossen wurde. + * + * Bewusst an einer Stelle fuer beide Wege – Webhook und Abgleich. Vorher + * setzte der Webhook nur den Status und liess dabei aus, was die Oberflaeche + * tut: die naechste Fassung einer wiederkehrenden Aufgabe anlegen und den + * Vorgang in den Verlauf schreiben. + * + * Gibt false zurueck, wenn nichts zu tun war (Aufgabe fehlt oder ist schon + * erledigt) – der Aufrufer kann daran zaehlen, was er bewirkt hat. + */ +export async function completeTaskFromIssue( + taskId: string, + herkunft: "webhook" | "abgleich", +): Promise { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { id: true, title: true, status: true, projectId: true, sourceIssue: true }, + }); + if (!task || task.status === "DONE") return false; + + await prisma.task.update({ + where: { id: task.id }, + data: { + status: "DONE", + doneAt: new Date(), + position: await nextPosition(task.projectId, "DONE"), + }, + }); + + // Wie beim Abhaken von Hand: eine wiederkehrende Aufgabe erzeugt ihre + // naechste Fassung. + const folge = await spawnRecurrence(task.id); + await refreshDerivedProgress(task.projectId); + + await recordActivity({ + projectId: task.projectId, + // Kein userId: das hat nicht ein Mensch in Nebula getan, sondern das + // Git-System gemeldet. + kind: "TASK_MOVED", + summary: + `Issue #${task.sourceIssue} geschlossen → „${shorten(task.title, 40)}“ erledigt` + + (herkunft === "abgleich" ? " (beim Abgleich bemerkt)" : "") + + (folge ? ", nächste angelegt" : ""), + meta: { taskId: task.id, issue: task.sourceIssue, source: herkunft }, + }); + + return true; +} + +/** Wie viele Issues ein Abgleich hoechstens einzeln nachfragt. */ +const MAX_NACHFRAGEN = 25; + +/** + * Gleicht beim Abgleich die verknuepften Issues ab: was in Git geschlossen + * ist, wird in Nebula abgehakt. + * + * Warum zusaetzlich zum Webhook: der Webhook wirkt sofort, setzt aber voraus, + * dass die Instanz von aussen erreichbar ist und der Haken eingetragen wurde. + * Ist er das nicht – oder faellt er einmal aus –, bliebe der Rueckweg sonst + * ganz aus. Hier kommt er verzoegert, aber verlaesslich. + * + * **Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine + * Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von + * Nebula aus fehlgeschlagen ist – dann wuerde ein symmetrischer Abgleich eine + * bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem + * Webhook vorbehalten, der ein echtes "reopened" sieht. + * + * Fehler werden geschluckt: ein nicht erreichbares Issue darf den Abgleich + * des Repositories nicht scheitern lassen. + */ +export async function reconcileLinkedIssues( + repositoryId: string, + client: GitClient, + ref: RepoRef, + /** Nummern, die der Snapshot gerade als offen gemeldet hat. */ + offeneNummern: number[] = [], +): Promise { + const offen = new Set(offeneNummern); + + const kandidaten = await prisma.task.findMany({ + where: { + sourceRepositoryId: repositoryId, + sourceIssue: { not: null }, + status: { not: "DONE" }, + }, + select: { id: true, sourceIssue: true }, + orderBy: { createdAt: "asc" }, + }); + + let abgehakt = 0; + let nachfragen = 0; + + for (const task of kandidaten) { + const nummer = task.sourceIssue; + if (nummer === null) continue; + + // Steht die Nummer in der Liste der offenen Issues, ist nichts zu tun – + // und der Aufruf gespart. Umgekehrt gilt das nicht: die Liste ist + // gedeckelt, aus dem Fehlen folgt also nichts. + if (offen.has(nummer)) continue; + + if (nachfragen >= MAX_NACHFRAGEN) break; + nachfragen += 1; + + try { + const zustand = await client.getIssueState(ref, nummer); + // null heisst "gibt es nicht mehr" – geloescht ist nicht erledigt, + // also bleibt die Aufgabe stehen. + if (zustand !== "closed") continue; + if (await completeTaskFromIssue(task.id, "abgleich")) abgehakt += 1; + } catch { + // Nichts erreichbar, nichts geaendert – der naechste Lauf versucht es. + } + } + + return abgehakt; +}