Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b60667633 |
+26
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,27 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<ConnectionCheck> {
|
||||
try {
|
||||
const version = await getJsonSafe<{ version?: string }>(
|
||||
|
||||
@@ -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<ConnectionCheck> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
|
||||
@@ -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<ConnectionCheck> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
|
||||
@@ -190,6 +190,17 @@ export interface GitClient {
|
||||
* geoeffnet – daher der Schalter statt zweier Methoden.
|
||||
*/
|
||||
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
|
||||
/**
|
||||
* 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<RepoListItem[]>;
|
||||
/** Vollstaendiger Stand eines Repositories. */
|
||||
|
||||
+22
-1
@@ -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<SyncResult>
|
||||
// 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) {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user