diff --git a/FEATURES.md b/FEATURES.md
index 0e673e8..81e6ea8 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -503,6 +503,78 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab.
+### 4.6 Aufgabe ↔ Git-Issue
+
+Die Verbindung geht in beide Richtungen — mit einer bewussten Grenze.
+
+**Nebula → Git.** Ein Knopf im Aufgabendialog legt zur Aufgabe ein Issue im
+verknüpften Repository an. Nummer und Adresse landen an der Aufgabe (`sourceIssue`,
+`sourceUrl`, `sourceRepositoryId` — dieselben Felder, die die Übernahme aus Issues seit
+v0.9.0 füllt, samt Eindeutigkeit je Repository gegen Dubletten). In den Rumpf kommt ein
+Rückverweis auf das Projekt.
+
+Ausdrücklich **per Knopf und nie nebenbei**: was in ein fremdes System geschrieben wird,
+soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledigt schließt
+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.
+
+**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
+`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
+außen geht deshalb nur die Grenze um „erledigt“; ein Rundlauf verliert *In Arbeit* und
+*Blockiert*. Labels wie `nebula:doing` wären die Alternative, würden aber den
+Issue-Tracker mit Zuständen füllen, die dort niemand pflegt. Die feine Abstufung bleibt
+in Nebula.
+
+Eine echte beidseitige Synchronisation mit Konfliktauflösung gibt es **nicht** — das ist
+der klassische Ort für stillen Datenverlust.
+
+**Kein Widerspruch zu „keine eigenen Git-Operationen“:** Issues laufen über die HTTP-API
+der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`, SSRF-Schutz
+inklusive). Kein Klon, kein Arbeitsverzeichnis. Schreibende Aufrufe folgen dabei
+**keinen Umleitungen** — ein 307 auf eine interne Adresse nähme den Rumpf und den Token
+im Header mit.
+
+### 4.7 Webhooks
+
+Ein Push aktualisiert den Stand sofort, statt bis zum nächsten Durchlauf des Zeitgebers
+zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Der Zeitplan bleibt als
+Rückfalllösung bestehen.
+
+Einzurichten am Repository (Funksymbol im Git-Bereich): Nebula erzeugt URL und
+Geheimnis, eingetragen wird der Haken im Git-System von Hand. Über die API anzulegen
+wäre machbar, verlangte aber Administrationsrechte am Repository — deutlich mehr, als
+Nebula sonst braucht.
+
+Das Geheimnis ist **genau einmal** zu sehen; danach liegt es verschlüsselt in der
+Datenbank, wie die Zugangstoken, und verlässt den Server nicht mehr.
+
+| System | Ausweis | Kopfzeile |
+| --- | --- | --- |
+| GitHub | HMAC-SHA256 über den Rumpf | `X-Hub-Signature-256: sha256=…` |
+| Gitea | dieselbe Rechnung, ohne Präfix | `X-Gitea-Signature` (ältere: `X-Hub-Signature-256`) |
+| GitLab | das Geheimnis im Klartext | `X-Gitlab-Token` |
+
+GitLabs Verfahren ist schwächer: es beweist nur, dass der Absender das Geheimnis kennt,
+nicht dass der Rumpf unverändert ist. Mehr gibt die Plattform nicht her; verglichen wird
+trotzdem zeitkonstant.
+
+Geprüft wird gegen den **rohen** Rumpf — durch `JSON.parse` und `JSON.stringify` gedreht
+käme eine andere Zeichenfolge heraus und die Signatur passte nie.
+
+Was der Endpunkt bewusst **nicht** tut: den Rumpf als Quelle für Inhalte benutzen. Titel,
+Beschreibung und Zustand kommen weiter aus Nebula beziehungsweise beim nächsten Abgleich
+aus der API. Wer das Geheimnis kennt, kann also höchstens eine Aufgabe auf- oder zumachen
+und einen Abgleich auslösen — keine beliebigen Daten einschleusen.
+
+Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler brächte das
+Git-System zum Wiederholen und irgendwann zum Abschalten des Hakens.
+
+Der Endpunkt ist der einzige ohne Anmeldung, der schreibt. Deshalb: Ratenbegrenzung je
+Repository (60/Minute), Deckel auf die Rumpfgröße, und ein unbekanntes Repository
+antwortet wie ein nicht eingerichtetes — mit 404, damit die ID nicht bestätigt wird.
+
---
## 4a. Export, Import und Sicherung
diff --git a/ROADMAP.md b/ROADMAP.md
index ad8ee31..98fb013 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,15 +27,6 @@ Migration wert.
---
-### 1.1 Webhooks statt Polling `M`
-
-Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
-zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
-Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
-erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
-
----
-
### 1.2 Läuft es noch? `M`
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
@@ -52,47 +43,21 @@ Sitzungen.
---
-### 1.4 v1.7.0 „Aufgaben und Issues" `M` + `M`
+### 1.4 Aufgaben ↔ Issues: was noch fehlt `S`
-Nachverfolgbarkeit zwischen Nebula-Aufgabe und Git. Die Zuordnung steht im
-Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt
-`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0
-(`tasks/from-issues`); es fehlt die Gegenrichtung.
+Mit v1.7.0 stehen beide Richtungen: „als Issue anlegen“ und der Rückweg über
+Webhooks. Offen geblieben ist Kleinkram:
-**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene
-Git-Operationen* — klonen, committen, pushen. Issues laufen über die
-HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`,
-SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis.
+- **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem
+ Projekt aus Frontend und Backend fehlt die Wahl.
+- **Beim Übernehmen aus Issues** wird der Rückweg nicht automatisch
+ eingerichtet — der Webhook ist getrennt zu setzen.
-Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.**
-Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst
-her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen.
-Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3.
-
-**Stufe 1 — eine Richtung, ausdrücklich** `M`
-Ein Knopf „als Issue anlegen" an der Aufgabe. Nummer und URL wandern in die
-vorhandenen Felder. Kein Automatismus: was nach außen geht, wird
-angestoßen, nicht nebenbei mitgeschrieben.
-
-**Stufe 2 — Rückweg über Webhooks** `M`
-Setzt 1.1 voraus. Geschlossenes Issue → Aufgabe nach `DONE`. Erst damit ist
-„synchron" ehrlich, ohne im Minutentakt fremde APIs zu fragen.
-
-**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
-`TODO/DOING/BLOCKED/DONE`, ein Issue nur offen/geschlossen. Ein Rundlauf
-verliert also `DOING` und `BLOCKED` — bewusst hingenommen: die feine
-Abstufung bleibt in Nebula, nach Git geht nur offen/zu. Labels wie
-`nebula:doing` wären die Alternative, würden aber den Issue-Tracker mit
-Zuständen zumüllen, die dort niemand pflegt.
-
-**Echte beidseitige Synchronisation mit Konfliktauflösung ist nicht
-vorgesehen.** Das ist der klassische Ort für stillen Datenverlust, und für
-ein persönliches Dashboard steht der Aufwand in keinem Verhältnis.
-
-Für Projekte ohne Issue-Tracker wäre eine `.nebula/tasks.md` über die
-Contents-API denkbar — aber nur als **einseitiger Spiegel**, nicht als
-Synchronisation: zwei Schreiber auf einer Datei, und jede Statusänderung
-ein Commit.
+Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige
+Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht
+aufeinander — ein Issue kennt nur offen und geschlossen, `DOING` und
+`BLOCKED` gehen im Rundlauf verloren. Das ist hingenommen, statt den
+Issue-Tracker mit Labels zu füllen, die dort niemand pflegt.
---
@@ -172,6 +137,7 @@ Damit klar ist, was **nicht** kommt und warum:
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
+| **v1.7.0** Aufgabe als Git-Issue anlegen, Webhooks für Push und Issues | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
diff --git a/package.json b/package.json
index b12e176..9fb664c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nebula",
- "version": "1.6.0",
+ "version": "1.7.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
diff --git a/prisma/migrations/20260815000000_repository_webhook/migration.sql b/prisma/migrations/20260815000000_repository_webhook/migration.sql
new file mode 100644
index 0000000..bdec992
--- /dev/null
+++ b/prisma/migrations/20260815000000_repository_webhook/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "Repository" ADD COLUMN "webhookLastAt" TIMESTAMP(3),
+ADD COLUMN "webhookSecret" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 909b5fd..5eaed02 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -407,6 +407,16 @@ model Repository {
notifiedCiState String?
notifiedRelease String?
+ /// Gemeinsames Geheimnis fuer eingehende Webhooks, verschluesselt wie die
+ /// Zugangstoken. Je Repository, weil der Haken im Git-System dort
+ /// eingetragen wird und sich einzeln zuruecknehmen laesst.
+ /// NULL heisst: fuer dieses Repository ist kein Webhook eingerichtet, und
+ /// eingehende Aufrufe werden abgewiesen.
+ webhookSecret String?
+ /// Wann zuletzt ein gueltiger Webhook ankam – die einzige Rueckmeldung
+ /// darueber, ob der Haken im Git-System wirklich funktioniert.
+ webhookLastAt DateTime?
+
snapshot RepoSnapshot?
createdAt DateTime @default(now())
diff --git a/src/app/api/repositories/[id]/webhook/route.ts b/src/app/api/repositories/[id]/webhook/route.ts
new file mode 100644
index 0000000..88f475b
--- /dev/null
+++ b/src/app/api/repositories/[id]/webhook/route.ts
@@ -0,0 +1,63 @@
+import { randomBytes } from "node:crypto";
+import { NextResponse } from "next/server";
+import { requireUserApi } from "@/lib/auth/guard";
+import { APP_URL } from "@/lib/config";
+import { encryptSecret } from "@/lib/crypto";
+import { prisma } from "@/lib/db";
+import { ownedRepository } from "@/lib/repositories";
+import { jsonError } from "@/lib/security/request";
+
+export const dynamic = "force-dynamic";
+
+type Params = { params: Promise<{ id: string }> };
+
+/**
+ * Richtet den Webhook eines Repositories ein oder nimmt ihn zurueck.
+ *
+ * Das Geheimnis wird hier erzeugt und **genau einmal** im Klartext
+ * zurueckgegeben – danach liegt es nur noch verschluesselt in der Datenbank,
+ * wie die Zugangstoken. Wer es verliert, erzeugt ein neues; das alte gilt
+ * dann nicht mehr.
+ *
+ * Eingetragen wird der Haken im Git-System von Hand. Ihn ueber die API
+ * anzulegen waere machbar, verlangte aber Administrationsrechte am
+ * Repository – deutlich mehr, als Nebula sonst braucht.
+ */
+export async function POST(_request: Request, { params }: Params) {
+ const auth = await requireUserApi();
+ if (!auth.ok) return auth.response;
+
+ const { id } = await params;
+ const repository = await ownedRepository(id, auth.user.id);
+ if (!repository) return jsonError("Repository nicht gefunden", 404);
+
+ // 32 Byte, hexadezimal. Lang genug, dass Raten aussichtslos ist, und
+ // ohne Sonderzeichen, die in Formularfeldern Aerger machen.
+ const secret = randomBytes(32).toString("hex");
+
+ await prisma.repository.update({
+ where: { id: repository.id },
+ data: { webhookSecret: encryptSecret(secret), webhookLastAt: null },
+ });
+
+ return NextResponse.json({
+ secret,
+ url: `${APP_URL}/api/webhooks/${repository.id}`,
+ });
+}
+
+export async function DELETE(_request: Request, { params }: Params) {
+ const auth = await requireUserApi();
+ if (!auth.ok) return auth.response;
+
+ const { id } = await params;
+ const repository = await ownedRepository(id, auth.user.id);
+ if (!repository) return jsonError("Repository nicht gefunden", 404);
+
+ await prisma.repository.update({
+ where: { id: repository.id },
+ data: { webhookSecret: null, webhookLastAt: null },
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/webhooks/[repositoryId]/route.ts b/src/app/api/webhooks/[repositoryId]/route.ts
new file mode 100644
index 0000000..5fa295c
--- /dev/null
+++ b/src/app/api/webhooks/[repositoryId]/route.ts
@@ -0,0 +1,136 @@
+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 { 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 !== task.status) {
+ await prisma.task.update({
+ where: { id: task.id },
+ data: {
+ status: ziel,
+ doneAt: ziel === "DONE" ? new Date() : 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 });
+}
diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx
index b975665..400254b 100644
--- a/src/app/projects/[id]/page.tsx
+++ b/src/app/projects/[id]/page.tsx
@@ -11,7 +11,7 @@ import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
-import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects";
+import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
import type { ConnectionSafe, ProjectDetail } from "@/types";
export const dynamic = "force-dynamic";
@@ -55,6 +55,9 @@ export default async function ProjectPage({ params }: PageProps) {
const detail = project as unknown as ProjectDetail;
+ // Nur die Angabe "hier haengt ein Webhook", nicht das Geheimnis selbst.
+ const webhookIds = await webhookRepositoryIds(detail.id);
+
const fuehrend = detail.repositories[0]?.snapshot;
const readme = fuehrend?.readme
? {
@@ -116,6 +119,7 @@ export default async function ProjectPage({ params }: PageProps) {
diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx
index 422b900..c38da89 100644
--- a/src/components/GitPanel.tsx
+++ b/src/components/GitPanel.tsx
@@ -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("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(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
)}
+