feat(git): Webhooks fuer Push und Issues, v1.7.0
CI / pruefen (push) Canceled after 0s

Ein Push aktualisiert den Stand sofort, statt bis zum naechsten Durchlauf
des Zeitgebers zu warten; ein geschlossenes Issue hakt seine Aufgabe ab.
Der Zeitplan bleibt als Rueckfalllosung.

Der Endpunkt ist der einzige ohne Anmeldung, der schreibt – entsprechend
eng gefasst:

- Ausgewiesen wird sich mit einer Signatur ueber den Rumpf. GitHub und
  Gitea rechnen HMAC-SHA256, GitLab schickt nur den Token im Klartext;
  dessen Verfahren ist schwaecher, mehr gibt die Plattform nicht her.
  Verglichen wird durchweg zeitkonstant.
- Geprueft wird gegen den **rohen** Rumpf. Durch JSON.parse und
  JSON.stringify gedreht kaeme eine andere Zeichenfolge heraus und die
  Signatur passte nie.
- Der Rumpf ist nie Quelle fuer Inhalte. Wer das Geheimnis kennt, kann eine
  Aufgabe auf- oder zumachen und einen Abgleich ausloesen – keine
  beliebigen Daten einschleusen.
- Ratenbegrenzung je Repository, Deckel auf die Rumpfgroesse, und ein
  unbekanntes Repository antwortet wie ein nicht eingerichtetes: 404,
  damit die ID nicht bestaetigt wird.
- Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler
  brachte das Git-System zum Wiederholen und irgendwann zum Abschalten.

Das Geheimnis liegt verschluesselt am Repository, wie die Zugangstoken, und
ist genau einmal zu sehen. Damit es nicht doch nach draussen wandert, laedt
repositoryInclude die Repositories nicht mehr per include, sondern ueber
eine ausdrueckliche Feldliste: ein include haette das neue Feld still in
jede Projektantwort und jede Seite mitgenommen. Ob ein Haken eingerichtet
ist, holt die Projektseite getrennt.

Die Migration legt zwei nullbare Spalten an, mehr nicht; der Gegenlauf von
migrate diff kommt leer heraus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:35:29 +02:00
co-authored by Claude Opus 5
parent e1d435f10a
commit c270038edd
15 changed files with 956 additions and 52 deletions
+36
View File
@@ -17,6 +17,7 @@ import {
Lock,
Package,
Plus,
Radio,
RefreshCw,
ShieldCheck,
Star,
@@ -26,6 +27,7 @@ import {
import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { WebhookDialog } from "@/components/WebhookDialog";
import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asCalendar, asRecord } from "@/types";
@@ -55,9 +57,13 @@ type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({
project,
connections,
webhookRepoIds = [],
}: {
project: ProjectDetail;
connections: ConnectionSafe[];
/** Repositories mit hinterlegtem Webhook. Das Geheimnis selbst bleibt
auf dem Server – hier kommt nur an, ob eines existiert. */
webhookRepoIds?: string[];
}) {
const router = useRouter();
const [tab, setTab] = useState<TabKey>("pulls");
@@ -65,6 +71,7 @@ export function GitPanel({
const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [webhookOpen, setWebhookOpen] = useState(false);
const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
@@ -72,6 +79,7 @@ export function GitPanel({
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false;
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
async function importIssues() {
@@ -280,6 +288,24 @@ export function GitPanel({
Repository
</button>
)}
<button
type="button"
onClick={() => setWebhookOpen(true)}
className={cn(
"rounded-lg p-2 transition-colors",
webhookAktiv
? "text-emerald-300 hover:bg-emerald-500/10"
: "text-slate-500 hover:bg-white/8 hover:text-violet-300",
)}
aria-label="Webhook einrichten"
title={
webhookAktiv
? "Webhook ist eingerichtet – meldet Aenderungen sofort"
: "Webhook einrichten – Aenderungen sofort statt beim naechsten Abgleich"
}
>
<Radio className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeRepository(repository)}
@@ -312,6 +338,16 @@ export function GitPanel({
connections={connections}
/>
<WebhookDialog
open={webhookOpen}
onClose={() => setWebhookOpen(false)}
repositoryId={repository.id}
repositoryTitle={repoTitle(repository)}
provider={repository.connection?.provider ?? null}
eingerichtet={webhookAktiv}
zuletzt={repository.webhookLastAt}
/>
{message ? (
<p
className={cn(
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Copy, Loader2, Radio, Trash2 } from "lucide-react";
import type { GitProvider } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { formatRelative } from "@/lib/utils";
/**
* Einrichtung des Webhooks fuer ein Repository.
*
* Der Haken wird im Git-System von Hand eingetragen. Ihn ueber die API
* anzulegen waere machbar, verlangte aber Administrationsrechte am
* Repository – deutlich mehr, als Nebula sonst braucht, und es waere ein
* Recht, das die meisten Token gar nicht haben.
*
* Das Geheimnis ist genau einmal zu sehen: danach liegt nur noch die
* verschluesselte Fassung in der Datenbank, wie bei den Zugangstoken.
*/
/** Wo der Haken im jeweiligen System einzutragen ist. */
const ANLEITUNG: Record<GitProvider, { pfad: string; ereignisse: string }> = {
GITHUB: {
pfad: "Settings → Webhooks → Add webhook",
ereignisse: "Content type „application/json“, Events: Pushes und Issues",
},
GITEA: {
pfad: "Einstellungen → Webhooks → Gitea",
ereignisse: "Auslöser: Push und Issues",
},
GITLAB: {
pfad: "Settings → Webhooks",
ereignisse: "Trigger: Push events und Issues events",
},
};
export function WebhookDialog({
open,
onClose,
repositoryId,
repositoryTitle,
provider,
eingerichtet,
zuletzt,
}: {
open: boolean;
onClose: () => void;
repositoryId: string;
repositoryTitle: string;
provider: GitProvider | null;
eingerichtet: boolean;
zuletzt: Date | string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const [geheimnis, setGeheimnis] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [kopiert, setKopiert] = useState<string | null>(null);
// Ohne Verbindung ist das System unbekannt. Dann lieber allgemein bleiben,
// als eine Anleitung fuer das falsche zu zeigen.
const anleitung = provider ? ANLEITUNG[provider] : null;
async function erzeugen() {
setBusy(true);
setFehler(null);
try {
const antwort = await fetch(`/api/repositories/${repositoryId}/webhook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const nutzlast = await antwort.json().catch(() => ({}));
if (!antwort.ok) throw new Error(nutzlast.error ?? "Fehlgeschlagen.");
setGeheimnis(nutzlast.secret);
setUrl(nutzlast.url);
router.refresh();
} catch (gefangen) {
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
} finally {
setBusy(false);
}
}
async function zuruecknehmen() {
if (!window.confirm("Webhook zuruecknehmen? Eingehende Aufrufe werden dann abgewiesen.")) {
return;
}
setBusy(true);
try {
await fetch(`/api/repositories/${repositoryId}/webhook`, { method: "DELETE" });
setGeheimnis(null);
setUrl(null);
router.refresh();
onClose();
} finally {
setBusy(false);
}
}
async function kopieren(wert: string, was: string) {
try {
await navigator.clipboard.writeText(wert);
setKopiert(was);
setTimeout(() => setKopiert(null), 2000);
} catch {
// Ohne Zwischenablage bleibt der Text zum Markieren stehen.
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Webhook einrichten"
description={`Damit meldet ${repositoryTitle} Aenderungen sofort, statt auf den naechsten Abgleich zu warten.`}
>
<div className="space-y-4">
{eingerichtet && !geheimnis ? (
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/8 px-3 py-2.5">
<p className="flex items-center gap-2 text-sm text-emerald-100">
<Radio className="h-4 w-4 shrink-0" />
Webhook ist eingerichtet.
</p>
<p className="mt-1 text-xs text-emerald-300/70" suppressHydrationWarning>
{zuletzt
? `Zuletzt empfangen ${formatRelative(zuletzt)}.`
: "Bisher ist noch kein Aufruf angekommen — der Haken im Git-System fehlt vermutlich noch."}
</p>
</div>
) : null}
{geheimnis && url ? (
<div className="space-y-3">
<Feld label="URL" wert={url} kopiert={kopiert === "url"} onCopy={() => kopieren(url, "url")} />
<Feld
label="Geheimnis (Secret)"
wert={geheimnis}
kopiert={kopiert === "secret"}
onCopy={() => kopieren(geheimnis, "secret")}
/>
<p className="rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-xs text-amber-200">
Das Geheimnis ist <strong>jetzt zum einzigen Mal</strong> zu sehen. Danach liegt es
nur noch verschluesselt in der Datenbank. Verloren? Dann hier ein neues erzeugen —
das alte gilt dann nicht mehr.
</p>
</div>
) : null}
<div className="rounded-lg border border-white/8 bg-white/2 px-3 py-2.5 text-xs text-slate-400">
<p className="font-semibold text-slate-300">Im Git-System eintragen</p>
{anleitung ? (
<>
<p className="mt-1">{anleitung.pfad}</p>
<p className="mt-0.5">{anleitung.ereignisse}</p>
</>
) : (
<p className="mt-1">
In den Einstellungen des Repositories unter „Webhooks“, mit den Ausloesern
Push und Issues. Ist eine Git-Verbindung hinterlegt, steht hier der genaue
Pfad fuer das jeweilige System.
</p>
)}
<p className="mt-2 text-slate-500">
Nebula wertet nur Push und Issues aus. Alles andere wird angenommen und liegen
gelassen — ein Fehler wuerde das Git-System sonst zum Wiederholen bringen.
</p>
</div>
{fehler ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{fehler}
</p>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
{eingerichtet ? (
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={zuruecknehmen}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Zuruecknehmen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
{geheimnis ? "Fertig" : "Schliessen"}
</button>
<button type="button" className="btn btn-primary" onClick={erzeugen} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Radio className="h-4 w-4" />}
{eingerichtet ? "Neues Geheimnis" : "Einrichten"}
</button>
</div>
</div>
</div>
</Modal>
);
}
function Feld({
label,
wert,
kopiert,
onCopy,
}: {
label: string;
wert: string;
kopiert: boolean;
onCopy: () => void;
}) {
return (
<div>
<span className="field-label">{label}</span>
<div className="flex items-center gap-2">
{/* tabIndex: der Kasten scrollt seitlich und muss deshalb auch mit
der Tastatur erreichbar sein – sonst kommt man an das Ende einer
langen URL nur mit der Maus. */}
<code
tabIndex={0}
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/9 bg-[rgba(8,10,24,0.6)] px-3 py-2 font-mono text-xs text-slate-200"
>
{wert}
</code>
<button
type="button"
onClick={onCopy}
className="btn btn-ghost shrink-0 px-2.5 py-2"
aria-label={`${label} kopieren`}
>
{kopiert ? <Check className="h-4 w-4 text-emerald-300" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
);
}