feat(sync): geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Behebt florian.wilde/nebula#1. Der Rueckweg "Issue geschlossen -> Aufgabe erledigt" gab es seit v1.7.0 schon, aber nur ueber den Webhook. Ist keiner eingerichtet - und am Repository war keiner eingetragen -, blieb er ganz aus. Damit hing die Funktion an einer Voraussetzung, die man ihr von aussen nicht ansieht. Der Abgleich fragt jetzt den Zustand verknuepfter Issues nach und hakt ab, was in Git geschlossen ist. Beides ergaenzt sich: der Webhook wirkt sofort, der Abgleich verlaesslich. Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen fuehrt, werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht - die Liste ist gedeckelt, aus dem Fehlen einer Nummer folgt nichts. Deshalb ein Deckel von 25 Nachfragen je Durchgang. **Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von Nebula aus fehlgeschlagen ist; ein symmetrischer Abgleich wuerde dann eine bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem Webhook vorbehalten, der ein echtes "reopened" sieht. Ein geloeschtes Issue ist ebenfalls kein erledigtes. Nebenbei eine Unstimmigkeit aus v1.7.0 geradegezogen: der Webhook setzte nur den Status und liess aus, was die Oberflaeche tut - naechste Fassung einer wiederkehrenden Aufgabe, Eintrag im Verlauf. Beide Wege gehen jetzt durch dieselbe Funktion. Geprueft: 232 Unit-Tests, 31 E2E-Tests. Der neue E2E-Test laeuft als einziger wirklich nach draussen - er haengt eine Aufgabe an ein seit Jahren geschlossenes Issue und prueft, dass der Abgleich sie abhakt. Ist GitHub nicht erreichbar, ueberspringt er sich statt rot zu werden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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