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>
143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { tryDecryptSecret } from "@/lib/crypto";
|
||
import { prisma } from "@/lib/db";
|
||
import { rateLimit } from "@/lib/security/rateLimit";
|
||
import { syncRepository } from "@/lib/sync";
|
||
import { completeTaskFromIssue, statusForIssueState } from "@/lib/tasks/issues";
|
||
import { refreshDerivedProgress } from "@/lib/tasks";
|
||
import { parseEvent, verifySignature } from "@/lib/webhooks/verify";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
type Params = { params: Promise<{ repositoryId: string }> };
|
||
|
||
/** Groesse des Rumpfes deckeln – ein Push-Ereignis ist nie ein Megabyte. */
|
||
const MAX_BODY = 512 * 1024;
|
||
|
||
/**
|
||
* Eingehende Webhooks von GitHub, GitLab und Gitea.
|
||
*
|
||
* Der einzige Endpunkt ohne Anmeldung, der etwas schreibt. Ausgewiesen wird
|
||
* sich mit einer Signatur ueber den Rumpf, geprueft gegen das Geheimnis, das
|
||
* beim Einrichten am Repository hinterlegt wurde.
|
||
*
|
||
* Absichtlich sparsam in dem, was er tut:
|
||
*
|
||
* push – stoesst denselben Abgleich an wie der Zeitgeber
|
||
* issues – geschlossen oder wieder geoeffnet wandert an die Aufgabe
|
||
*
|
||
* Alles andere wird angenommen und liegen gelassen. Ein Webhook, der mit
|
||
* einem Fehler antwortet, wird von den Systemen wiederholt und irgendwann
|
||
* abgeschaltet – deshalb ist "verstanden, nichts zu tun" die richtige
|
||
* Antwort auf ein unbekanntes Ereignis.
|
||
*
|
||
* Was hier NICHT passiert: der Rumpf wird nie als Quelle fuer Inhalte
|
||
* benutzt. Titel, Beschreibung und Zustand einer Aufgabe kommen weiter aus
|
||
* Nebula beziehungsweise beim naechsten Abgleich aus der API. Wer das
|
||
* Geheimnis kennt, kann also hoechstens eine Aufgabe auf- oder zumachen und
|
||
* einen Abgleich ausloesen – nicht beliebige Daten einschleusen.
|
||
*/
|
||
export async function POST(request: Request, { params }: Params) {
|
||
const { repositoryId } = await params;
|
||
|
||
// Die ID steht in der URL und ist damit halboeffentlich. Ein Deckel
|
||
// dagegen, sie als Orakel zu benutzen oder den Abgleich zu verheizen.
|
||
const limit = rateLimit(`webhook:${repositoryId}`, 60, 60_000);
|
||
if (!limit.ok) {
|
||
return NextResponse.json(
|
||
{ error: "Zu viele Aufrufe." },
|
||
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
|
||
);
|
||
}
|
||
|
||
const repository = await prisma.repository.findUnique({
|
||
where: { id: repositoryId },
|
||
include: { connection: true },
|
||
});
|
||
|
||
// Unbekanntes Repository und fehlendes Geheimnis sind dieselbe Antwort:
|
||
// ob es die ID gibt, geht den Anrufer nichts an.
|
||
const secret = repository?.webhookSecret
|
||
? tryDecryptSecret(repository.webhookSecret)
|
||
: null;
|
||
if (!repository || !secret) {
|
||
return NextResponse.json({ error: "Unbekannt." }, { status: 404 });
|
||
}
|
||
|
||
const roh = await request.text();
|
||
if (roh.length > MAX_BODY) {
|
||
return NextResponse.json({ error: "Rumpf zu gross." }, { status: 413 });
|
||
}
|
||
|
||
/**
|
||
* Der **rohe** Rumpf geht in die Pruefung, nicht das geparste Objekt:
|
||
* durch JSON.parse und JSON.stringify gedreht kaeme eine andere Zeichen-
|
||
* folge heraus und die Signatur passte nie.
|
||
*/
|
||
const provider = repository.connection?.provider ?? "GITEA";
|
||
const geprueft = verifySignature(provider, secret, roh, request.headers);
|
||
if (!geprueft.ok) {
|
||
return NextResponse.json({ error: "Signatur ungueltig." }, { status: 401 });
|
||
}
|
||
|
||
let payload: unknown = null;
|
||
try {
|
||
payload = JSON.parse(roh);
|
||
} catch {
|
||
return NextResponse.json({ error: "Kein gueltiges JSON." }, { status: 400 });
|
||
}
|
||
|
||
const eventName =
|
||
request.headers.get("x-github-event") ??
|
||
request.headers.get("x-gitea-event") ??
|
||
request.headers.get("x-gitlab-event");
|
||
|
||
const ereignis = parseEvent(provider, eventName, payload);
|
||
|
||
// Ab hier gilt der Aufruf als echt – das ist die Rueckmeldung darueber,
|
||
// ob der Haken im Git-System ueberhaupt funktioniert.
|
||
await prisma.repository.update({
|
||
where: { id: repository.id },
|
||
data: { webhookLastAt: new Date() },
|
||
});
|
||
|
||
if (ereignis.kind === "push") {
|
||
// Derselbe Weg wie beim Zeitgeber – nur eben sofort statt beim
|
||
// naechsten Durchlauf.
|
||
await syncRepository(repository.id).catch(() => undefined);
|
||
return NextResponse.json({ ok: true, handled: "push" });
|
||
}
|
||
|
||
if (ereignis.kind === "issue") {
|
||
const task = await prisma.task.findFirst({
|
||
where: { sourceRepositoryId: repository.id, sourceIssue: ereignis.number },
|
||
select: { id: true, status: true, projectId: true },
|
||
});
|
||
if (!task) {
|
||
return NextResponse.json({ ok: true, handled: "issue", matched: false });
|
||
}
|
||
|
||
const ziel = statusForIssueState(ereignis.open, 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: null },
|
||
});
|
||
await refreshDerivedProgress(task.projectId);
|
||
}
|
||
|
||
return NextResponse.json({ ok: true, handled: "issue", matched: true, status: ziel });
|
||
}
|
||
|
||
return NextResponse.json({ ok: true, handled: "ignored", event: ereignis.name });
|
||
}
|