diff --git a/.env.example b/.env.example index 723ae1c..dd447c1 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,13 @@ MONITOR_ADMIN_API_KEY=change-me # die relative URL /api/v1 verwendet). MONITOR_API_BASE=http://localhost:8091/api/v1 -# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden -CODEBERG_OWNER=fds2610 -CODEBERG_REPO=monitor +# Codeberg-Projekte, aus denen Backlog/Done per Sync-Skript befuellt werden, sind seit +# Issue #27 keine Umgebungsvariable mehr, sondern ueber ein Modal im Frontend (Klick auf den +# PO) konfigurierbar - Default beim ersten Start sind fds2610/monitor (gelb), +# fds2610/Steuerkram (gruen) und fds2610/nc-gantt (blau), siehe +# api/src/Db/migrations/003_repo_configs.sql. Aendern erfordert MONITOR_ADMIN_API_KEY (siehe +# oben), das Modal fragt den Key bei der ersten Aenderung einmalig ab. + # Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind - bei kleinem # MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert CODEBERG_TOKEN= diff --git a/API.md b/API.md index b89663a..5cb14b8 100644 --- a/API.md +++ b/API.md @@ -18,6 +18,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite - [Systeme](#systeme) - [KPIs](#kpis) - [Live-Updates (SSE)](#live-updates-sse) +- [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren) - [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone) - [Fehlerformat](#fehlerformat) @@ -45,7 +46,7 @@ Zwei Schluesseltypen, beide statisch (F14): | Header | Verwendet fuer | Herkunft | |---|---|---| -| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` | +| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten), `POST`/`PATCH`/`DELETE /api/v1/repo-configs` (Projekte des Codeberg-Sync) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` | | `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben | Ausnahme ist der Codeberg-Webhook (`POST /api/v1/webhooks/codeberg`): Codeberg kennt unsere @@ -134,11 +135,16 @@ Response 200: aktualisiertes Agent-Objekt. Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`. - `backlog`, `bereit`, `in_arbeit`, `testing` und `done` werden regelmaessig aus - Codeberg-Issues befuellt (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und - `fertig` sind fuer zukuenftige Labels reserviert und ergeben sich ausschliesslich aus - Agenten-Meldungen (F4). -- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten - Issue-Label uebernommen (F4). + Codeberg-Issues aus mehreren konfigurierten Projekten befuellt (siehe + [Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und `fertig` sind fuer zukuenftige + Labels reserviert und ergeben sich ausschliesslich aus Agenten-Meldungen (F4). +- Ticket-Typ (statt Prioritaet) wird bei Codeberg-Tickets aus dem ersten Issue-Label + uebernommen, die Farbe dagegen fest aus dem Quell-Projekt (F4/F26) - so bleiben Tickets aus + unterschiedlichen Codeberg-Projekten auf einen Blick unterscheidbar, unabhaengig davon, + welche Labels dort jeweils existieren. +- `repo` (Format `owner/name`) identifiziert bei Codeberg-Tickets das Quell-Projekt und macht + `external_ref` (die Issue-Nummer) nur je Projekt eindeutig - Issue #5 in `fds2610/monitor` + und Issue #5 in `fds2610/Steuerkram` sind unterschiedliche Tickets. - Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik; die API liefert Tickets dafuer sortiert nach Spalte und Alter. @@ -146,7 +152,7 @@ Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done Query-Parameter: `column` (optional, filtert auf eine Spalte). -Response: `{ "tickets": [ { "id", "external_ref", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }` +Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }` ### `POST /api/v1/tickets` — Ticket anlegen @@ -263,11 +269,31 @@ Ohne Aenderung sendet der Server stattdessen alle `MONITOR_SSE_POLL_INTERVAL` Se Kommentar (`: heartbeat`) als Keep-Alive, damit Proxys die Verbindung nicht vorzeitig schliessen. +## Projekte (Codeberg-Sync konfigurieren) + +Seit Issue #27 ist die Projektliste des Codeberg-Sync (vorher `CODEBERG_REPOS`) in der +Datenbank gepflegt statt in einer Umgebungsvariable - editierbar per API bzw. ueber das +Frontend-Modal (Klick auf den PO in der Buero-Visualisierung). Erster Start seedet die drei +bisherigen Default-Projekte `fds2610/monitor` (gelb), `fds2610/Steuerkram` (gruen) und +`fds2610/nc-gantt` (blau), siehe `api/src/Db/migrations/003_repo_configs.sql`. + +| Endpunkt | Auth | Beschreibung | +|---|---|---| +| `GET /api/v1/repo-configs` | - | Liste aller konfigurierten Projekte, sortiert nach `sort_order` | +| `POST /api/v1/repo-configs` | `X-Admin-Key` | Legt ein Projekt an (`owner`, `repo`, `color` als 6-stelliger Hex-Wert, mit oder ohne `#`) | +| `PATCH /api/v1/repo-configs/{id}` | `X-Admin-Key` | Aendert `owner`/`repo`/`color` eines Projekts | +| `DELETE /api/v1/repo-configs/{id}` | `X-Admin-Key` | Entfernt ein Projekt (der Sync liest es danach nicht mehr) | + +`owner`+`repo` muessen zusammen eindeutig sein (409 -> hier als `422` mit Fehlermeldung, da +kein Konflikt-Statuscode im Fehlerformat vorgesehen ist). Der laufende `sync`-Dienst +(`--watch`) liest die Projektliste bei jedem Lauf frisch ein - eine Aenderung wirkt also ohne +Neustart des Containers. + ## Codeberg-Sync (Backlog/Done) -`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus dem konfigurierten -Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external_ref` -(= Issue-Nummer) mit den Tickets ab: +`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten +Codeberg-Projekten (siehe [Projekte](#projekte-codeberg-sync-konfigurieren)) und gleicht sie +je Projekt ueber `repo` + `external_ref` (= Issue-Nummer) mit den Tickets ab: - Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`, sonst `automate` → `bereit`, sonst `backlog`. @@ -275,7 +301,7 @@ Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external - Nur falls das Ticket noch in einer der Sync-Spalten (`backlog`, `bereit`, `in_arbeit`, `testing`, `done`) ist - auf `qa`/`fertig` manuell verschobene Tickets werden nicht zurueckgesetzt. -- Ticket-Typ/-Farbe kommen vom ersten Issue-Label (Name/Farbe). +- Ticket-Typ kommt vom ersten Issue-Label, die Farbe fest vom Projekt (siehe oben). Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe [docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit @@ -312,8 +338,14 @@ erfolgreichen Lauf gilt sofort wieder das regulaere Intervall. Im Log sieht das Damit Issue-Aenderungen nicht erst beim naechsten periodischen Lauf auf dem Board landen, kann Codeberg jede Aenderung aktiv melden. Der Endpunkt wertet den Payload **nicht** aus, er fordert nur einen regulaeren Sync an; der `sync`-Service greift die Anforderung im -Sekundentakt auf und laedt den aktuellen Stand. Vom Aendern des Issues bis zum Board -vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend). +Sekundentakt auf und laedt den aktuellen Stand **aller** konfigurierten Projekte (siehe +[Projekte](#projekte-codeberg-sync-konfigurieren)), nicht nur das des ausloesenden Repos. Vom Aendern des Issues bis zum +Board vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend). + +Bei mehreren Projekten muss der Webhook in **jedem** Repo eingerichtet werden, dessen +Aenderungen sofort statt erst beim naechsten periodischen Lauf ankommen sollen - ein +fehlender Webhook auf einem der Projekte faengt der periodische Sync (`MONITOR_SYNC_INTERVAL`) +weiterhin ab. Authentifizierung ist **kein** API-Key, sondern die HMAC-Signatur, die Codeberg selbst mitschickt: `X-Forgejo-Signature` (bzw. `X-Gitea-Signature`) enthaelt den HMAC-SHA256 des diff --git a/README.md b/README.md index 6f65613..54c161b 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,16 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und siehe Visualization.md Abschnitt 9). Ist die API nicht erreichbar, zeigt die Szene ersatzweise Demo-Daten — kenntlich gemacht durch einen roten Banner „API nicht erreichbar!". Die Basis-URL der API kommt aus `MONITOR_API_BASE` (siehe `.env.example`). +- Klick auf den PO öffnet ein Modal, in dem sich die vom Codeberg-Sync gelesenen Projekte + (Owner/Repo + Farbe) hinzufügen, bearbeiten und löschen lassen (Issue #27) — Änderungen + wirken sofort im laufenden `sync`-Dienst, ohne Neustart. Schreibende Aktionen fragen einmalig + den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser. - Codeberg-Sync als Dauerläufer (Compose-Service `sync`): füllt Backlog und Done automatisch - aus den Issues des konfigurierten Codeberg-Repos — periodisch alle `MONITOR_SYNC_INTERVAL` - Sekunden und, wenn ein Webhook eingerichtet ist, innerhalb von Sekunden nach jeder - Issue-Änderung (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per - `CODEBERG_WEBHOOK_SECRET`). Siehe [API.md](API.md) Abschnitt „Codeberg-Sync" — - dort steht auch die Einrichtung im Codeberg-Repo. + aus den Issues mehrerer konfigurierter Codeberg-Projekte (je Projekt mit eigener + Ticket-Farbe, siehe oben) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein + Webhook eingerichtet ist, innerhalb von Sekunden nach jeder Issue-Änderung + (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per `CODEBERG_WEBHOOK_SECRET`). Siehe + [API.md](API.md) Abschnitt „Codeberg-Sync" — dort steht auch die Einrichtung im Codeberg-Repo. - Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php index 0c72cfa..c0683fe 100755 --- a/api/bin/sync-codeberg.php +++ b/api/bin/sync-codeberg.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Monitor\Api\Config; use Monitor\Api\Db\Database; +use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergSyncService; use Monitor\Api\Sync\CurlCodebergClient; @@ -19,8 +20,7 @@ $db = Database::forFile($config->dbPath()); $service = new CodebergSyncService( new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()), new TicketRepository($db), - $config->codebergOwner(), - $config->codebergRepo(), + new RepoConfigRepository($db), ); // Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README). diff --git a/api/src/Application.php b/api/src/Application.php index e8738e1..811f23f 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -11,6 +11,7 @@ use Monitor\Api\Auth\WebhookAuthenticator; use Monitor\Api\Controller\AgentController; use Monitor\Api\Controller\EventController; use Monitor\Api\Controller\KpiController; +use Monitor\Api\Controller\RepoConfigController; use Monitor\Api\Controller\StreamController; use Monitor\Api\Controller\SystemController; use Monitor\Api\Controller\TicketController; @@ -26,6 +27,7 @@ use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\LegacyStatusFileReader; +use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\SyncTrigger; @@ -50,6 +52,7 @@ final class Application $events = new EventRepository($db); $systems = new SystemRepository($db); $kpis = new KpiRepository($db); + $repoConfigs = new RepoConfigRepository($db); $this->agentAuth = new AgentAuthenticator($agents); $this->adminAuth = new AdminAuthenticator($config->adminApiKey()); @@ -60,6 +63,7 @@ final class Application $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $kpiController = new KpiController($kpis); + $repoConfigController = new RepoConfigController($repoConfigs); $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath())); $streamController = new StreamController( new ChangeFeed($tickets, $events, $systems, $kpis, $agents), @@ -82,6 +86,14 @@ final class Application $this->router->add('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r)); $this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent'); + // Issue #27: Welche Codeberg-Projekte der Sync liest, ist ueber ein Modal im Frontend + // (Klick auf den PO) editierbar - schreibend daher hinter dem Admin-Key wie die + // Agenten-Registrierung, nicht hinter einem Agenten-Key (kein Bot-Vorgang). + $this->router->add('GET', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->index($r)); + $this->router->add('POST', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->create($r), 'admin'); + $this->router->add('PATCH', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->update($r, $p), 'admin'); + $this->router->add('DELETE', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->delete($r, $p), 'admin'); + $this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r)); $this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin'); $this->router->add( diff --git a/api/src/Config.php b/api/src/Config.php index d332f6d..47d7390 100644 --- a/api/src/Config.php +++ b/api/src/Config.php @@ -54,16 +54,6 @@ final class Config return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/'); } - public function codebergOwner(): string - { - return (string) $this->get('CODEBERG_OWNER', 'fds2610'); - } - - public function codebergRepo(): string - { - return (string) $this->get('CODEBERG_REPO', 'monitor'); - } - public function codebergToken(): ?string { return $this->get('CODEBERG_TOKEN'); diff --git a/api/src/Controller/RepoConfigController.php b/api/src/Controller/RepoConfigController.php new file mode 100644 index 0000000..a1eb820 --- /dev/null +++ b/api/src/Controller/RepoConfigController.php @@ -0,0 +1,53 @@ + $this->repoConfigs->all()]); + } + + public function create(Request $request): Response + { + try { + $repo = $this->repoConfigs->create($request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['repo' => $repo], 201); + } + + /** @param array $params */ + public function update(Request $request, array $params): Response + { + try { + $repo = $this->repoConfigs->update((int) $params['id'], $request->body); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['repo' => $repo]); + } + + /** @param array $params */ + public function delete(Request $request, array $params): Response + { + $this->repoConfigs->delete((int) $params['id']); + + return Response::json(['deleted' => true]); + } +} diff --git a/api/src/Db/Database.php b/api/src/Db/Database.php index 238f3a0..c353a20 100644 --- a/api/src/Db/Database.php +++ b/api/src/Db/Database.php @@ -43,14 +43,36 @@ final class Database return $db; } + /** + * Fuehrt jede Migration in migrations/ genau einmal aus (Tracking in schema_migrations). + * Noetig, seit Migrationen nicht mehr rein additiv sind (z.B. ALTER TABLE ... ADD COLUMN + * in 002_multi_repo_tickets.sql) - ein erneutes exec() bei jedem Request/Test-Setup + * (forFile()/inMemory() rufen migrate() bei jeder Instanziierung auf) wuerde sonst ab + * dem zweiten Aufruf mit "duplicate column name" abbrechen. + */ public function migrate(): void { + $this->pdo->exec( + 'CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL)' + ); + $applied = $this->pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN); + $migrationsDir = __DIR__ . '/migrations'; $files = glob($migrationsDir . '/*.sql') ?: []; sort($files); + $markApplied = $this->pdo->prepare( + 'INSERT INTO schema_migrations (filename, applied_at) VALUES (:filename, :applied_at)' + ); + foreach ($files as $file) { + $name = basename($file); + if (in_array($name, $applied, true)) { + continue; + } + $this->pdo->exec((string) file_get_contents($file)); + $markApplied->execute(['filename' => $name, 'applied_at' => $this->now()]); } } diff --git a/api/src/Db/migrations/002_multi_repo_tickets.sql b/api/src/Db/migrations/002_multi_repo_tickets.sql new file mode 100644 index 0000000..f4a7cea --- /dev/null +++ b/api/src/Db/migrations/002_multi_repo_tickets.sql @@ -0,0 +1,11 @@ +-- F26: Tickets aus mehreren Codeberg-Projekten unterscheiden. "repo" speichert das +-- Quell-Repo als "owner/name" und macht external_ref nur noch je Repo eindeutig - Issue #5 +-- in fds2610/monitor und Issue #5 in fds2610/Steuerkram sind zwei unterschiedliche Tickets. +ALTER TABLE tickets ADD COLUMN repo TEXT; + +-- Vor dieser Migration gab es nur den einen synchronisierten Repo. +UPDATE tickets SET repo = 'fds2610/monitor' WHERE source = 'codeberg' AND repo IS NULL; + +DROP INDEX IF EXISTS idx_tickets_external_ref; +CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_external_ref ON tickets (repo, external_ref) + WHERE external_ref IS NOT NULL; diff --git a/api/src/Db/migrations/003_repo_configs.sql b/api/src/Db/migrations/003_repo_configs.sql new file mode 100644 index 0000000..a7f8699 --- /dev/null +++ b/api/src/Db/migrations/003_repo_configs.sql @@ -0,0 +1,20 @@ +-- Issue #27: Welche Codeberg-Projekte der Sync liest (bisher CODEBERG_REPOS, siehe +-- 002_multi_repo_tickets.sql) wird jetzt in der Datenbank gepflegt statt in einer +-- Umgebungsvariable, damit sich das ueber ein Modal im Frontend (Klick auf den PO) aendern +-- laesst, ohne Container neu zu starten. Seed = die bisherigen drei Default-Projekte. +CREATE TABLE IF NOT EXISTS repo_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + color TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_configs_owner_repo ON repo_configs (owner, repo); + +INSERT INTO repo_configs (owner, repo, color, sort_order, created_at, updated_at) VALUES + ('fds2610', 'monitor', '#fbca04', 0, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + ('fds2610', 'Steuerkram', '#28a745', 1, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + ('fds2610', 'nc-gantt', '#0366d6', 2, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now')); diff --git a/api/src/Repository/RepoConfigRepository.php b/api/src/Repository/RepoConfigRepository.php new file mode 100644 index 0000000..0907bd5 --- /dev/null +++ b/api/src/Repository/RepoConfigRepository.php @@ -0,0 +1,148 @@ + */ + public function all(): array + { + $stmt = $this->db->pdo()->query('SELECT * FROM repo_configs ORDER BY sort_order ASC, id ASC'); + + return $stmt->fetchAll(); + } + + /** @return array|null */ + public function find(int $id): ?array + { + $stmt = $this->db->pdo()->prepare('SELECT * FROM repo_configs WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + /** @param array $data @return array */ + public function create(array $data): array + { + [$owner, $repo, $color] = $this->validate($data, null); + $now = $this->db->now(); + + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO repo_configs (owner, repo, color, sort_order, created_at, updated_at) + VALUES (:owner, :repo, :color, :sort_order, :created_at, :updated_at)' + ); + $stmt->execute([ + 'owner' => $owner, + 'repo' => $repo, + 'color' => $color, + 'sort_order' => $this->nextSortOrder(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $this->find((int) $this->db->pdo()->lastInsertId()); + } + + /** @param array $data @return array */ + public function update(int $id, array $data): array + { + if ($this->find($id) === null) { + throw new NotFoundException('Projekt nicht gefunden: ' . $id); + } + + [$owner, $repo, $color] = $this->validate($data, $id); + + $stmt = $this->db->pdo()->prepare( + 'UPDATE repo_configs SET owner = :owner, repo = :repo, color = :color, updated_at = :updated_at + WHERE id = :id' + ); + $stmt->execute([ + 'id' => $id, + 'owner' => $owner, + 'repo' => $repo, + 'color' => $color, + 'updated_at' => $this->db->now(), + ]); + + return $this->find($id); + } + + public function delete(int $id): void + { + if ($this->find($id) === null) { + throw new NotFoundException('Projekt nicht gefunden: ' . $id); + } + + $stmt = $this->db->pdo()->prepare('DELETE FROM repo_configs WHERE id = :id'); + $stmt->execute(['id' => $id]); + } + + /** + * Prueft Pflichtfelder + Farbformat vorab, statt eine Verletzung der UNIQUE-Constraint + * (owner, repo) als PDOException auflaufen zu lassen (Konvention wie + * AgentRepository::updateActivity() fuer das Fremdschluessel-Pendant bei Tickets). + * + * @param array $data + * @param int|null $excludeId - beim Update die eigene Zeile von der Duplikatspruefung ausnehmen. + * @return array{0: string, 1: string, 2: string} + */ + private function validate(array $data, ?int $excludeId): array + { + $owner = trim((string) ($data['owner'] ?? '')); + $repo = trim((string) ($data['repo'] ?? '')); + $color = $data['color'] ?? null; + + if ($owner === '' || $repo === '') { + throw new InvalidArgumentException('Felder "owner" und "repo" sind erforderlich.'); + } + + if (!is_string($color) || !preg_match('/^#?[0-9a-fA-F]{6}$/', $color)) { + throw new InvalidArgumentException('Feld "color" muss ein 6-stelliger Hex-Farbwert sein (z. B. "#fbca04").'); + } + + if ($this->existsWithOwnerRepo($owner, $repo, $excludeId)) { + throw new InvalidArgumentException(sprintf('Projekt "%s/%s" ist bereits konfiguriert.', $owner, $repo)); + } + + return [$owner, $repo, '#' . ltrim($color, '#')]; + } + + private function existsWithOwnerRepo(string $owner, string $repo, ?int $excludeId): bool + { + $sql = 'SELECT COUNT(*) FROM repo_configs WHERE owner = :owner AND repo = :repo'; + $params = ['owner' => $owner, 'repo' => $repo]; + if ($excludeId !== null) { + $sql .= ' AND id != :exclude_id'; + $params['exclude_id'] = $excludeId; + } + + $stmt = $this->db->pdo()->prepare($sql); + $stmt->execute($params); + + return ((int) $stmt->fetchColumn()) > 0; + } + + private function nextSortOrder(): int + { + $row = $this->db->pdo()->query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM repo_configs')->fetch(); + + return (int) $row['next']; + } +} diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index 34f4f23..011ce94 100644 --- a/api/src/Repository/TicketRepository.php +++ b/api/src/Repository/TicketRepository.php @@ -33,11 +33,12 @@ final class TicketRepository $now = $this->db->now(); $stmt = $this->db->pdo()->prepare( - 'INSERT INTO tickets (external_ref, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at) - VALUES (:external_ref, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)' + 'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at) + VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)' ); $stmt->execute([ 'external_ref' => $data['external_ref'] ?? null, + 'repo' => $data['repo'] ?? null, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'column_name' => $column, @@ -101,11 +102,17 @@ final class TicketRepository return $row === false ? null : $row; } - /** @return array|null */ - public function findByExternalRef(string $externalRef): ?array + /** + * external_ref (Issue-Nummer) ist nur je Repo eindeutig (F26: mehrere Codeberg-Projekte), + * daher zusaetzlich ueber "repo" ("owner/name") gescoped. $repo bleibt optional/null fuer + * Tickets ohne Codeberg-Projektbezug. + * + * @return array|null + */ + public function findByExternalRef(string $externalRef, ?string $repo = null): ?array { - $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref'); - $stmt->execute(['ref' => $externalRef]); + $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref AND repo IS :repo'); + $stmt->execute(['ref' => $externalRef, 'repo' => $repo]); $row = $stmt->fetch(); return $row === false ? null : $row; @@ -143,7 +150,7 @@ final class TicketRepository */ public function upsertFromCodeberg(array $data): array { - $existing = $this->findByExternalRef((string) $data['external_ref']); + $existing = $this->findByExternalRef((string) $data['external_ref'], $data['repo'] ?? null); if ($existing === null) { return $this->create($data + ['source' => 'codeberg']); } diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 4f04db4..f47ca2d 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -4,14 +4,17 @@ declare(strict_types=1); namespace Monitor\Api\Sync; +use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; /** - * F4: "Backlog und Done werden regelmaessig von Codeberg ausgelesen." Offene Issues landen - * (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) je nach Label in Backlog, - * Bereit, In Arbeit oder Testing, geschlossene in Done. Ticket-Typ und Farbe werden aus dem - * ersten Codeberg-Label uebernommen (F4: "Ticket-Typen ... koennen mit den Codeberg-Farben - * dargestellt werden"). + * F4/F26: Liest offene und geschlossene Issues aus mehreren konfigurierten Codeberg-Projekten + * (RepoConfigRepository, Issue #27 vormals Config::codebergRepos()) und legt daraus Tickets + * an bzw. aktualisiert sie. Offene Issues landen (falls noch nicht vorhanden bzw. noch nicht + * manuell bewegt) je nach Label in Backlog, Bereit, In Arbeit oder Testing, geschlossene in + * Done. Der Ticket-Typ (Badge-Text) kommt vom ersten Codeberg-Label, die Ticket-Farbe dagegen + * fest vom Quell-Projekt (Issue #26: "unterschiedliche Codeberg-Projekte sollen an fest + * zugeordneten Farben erkennbar sein"), nicht mehr von der Label-Farbe. */ final class CodebergSyncService { @@ -28,19 +31,34 @@ final class CodebergSyncService public function __construct( private readonly CodebergClient $client, private readonly TicketRepository $tickets, - private readonly string $owner, - private readonly string $repo, + private readonly RepoConfigRepository $repoConfigs, ) { } + /** + * Liest die Projektliste bei jedem Lauf frisch (statt einmalig im Konstruktor), damit + * eine Aenderung ueber das Frontend-Modal (Issue #27) im laufenden --watch-Prozess ohne + * Neustart wirkt. + */ public function sync(): void { - foreach ($this->client->listIssues($this->owner, $this->repo, 'open') as $issue) { - $this->tickets->upsertFromCodeberg($this->mapIssue($issue, $this->columnForOpenIssue($issue))); + foreach ($this->repoConfigs->all() as $repoConfig) { + $this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']); + } + } + + private function syncRepo(string $owner, string $repo, string $color): void + { + $slug = $owner . '/' . $repo; + + foreach ($this->client->listIssues($owner, $repo, 'open') as $issue) { + $this->tickets->upsertFromCodeberg( + $this->mapIssue($issue, $this->columnForOpenIssue($issue), $slug, $color) + ); } - foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) { - $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done')); + foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) { + $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done', $slug, $color)); } } @@ -67,29 +85,18 @@ final class CodebergSyncService * @param array{number: int, title: string, body: ?string, labels: array} $issue * @return array */ - private function mapIssue(array $issue, string $column): array + private function mapIssue(array $issue, string $column, string $repoSlug, string $color): array { $firstLabel = $issue['labels'][0] ?? null; return [ 'external_ref' => (string) $issue['number'], + 'repo' => $repoSlug, 'title' => $issue['title'], 'description' => $issue['body'], 'column' => $column, 'type' => $firstLabel['name'] ?? null, - 'color' => self::normalizeColor($firstLabel['color'] ?? null), + 'color' => $color, ]; } - - /** - * Codeberg liefert Label-Farben mal mit, mal ohne fuehrendes "#". Die Normalisierung - * gehoert hierher und nicht in den Client, damit jede CodebergClient-Implementierung - * dieselbe CSS-taugliche Farbe im Ticket ablegt. - */ - private static function normalizeColor(?string $color): ?string - { - $color = trim((string) $color); - - return $color === '' ? null : '#' . ltrim($color, '#'); - } } diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index c3a7180..7f9d0c5 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -119,6 +119,62 @@ final class ApplicationTest extends TestCase self::assertSame(404, $response->status); } + /** + * Issue #27: die Projektliste des Codeberg-Sync ist ueber ein Modal im Frontend + * editierbar - lesend oeffentlich (fuer das Modal beim Oeffnen), schreibend hinter dem + * Admin-Key wie die Agenten-Registrierung. + */ + public function testRepoConfigsIndexIsPublic(): void + { + $response = $this->app->handle(Request::create('GET', '/api/v1/repo-configs')); + + self::assertSame(200, $response->status); + self::assertIsArray($response->body()['repos']); + } + + public function testCreateRepoConfigRequiresAdminKey(): void + { + $response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04', + ])); + + self::assertSame(401, $response->status); + } + + public function testFullFlowCreateUpdateAndDeleteRepoConfigWithAdminKey(): void + { + $createResponse = $this->app->handle(Request::create( + 'POST', + '/api/v1/repo-configs', + body: ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + self::assertSame(201, $createResponse->status); + $id = $createResponse->body()['repo']['id']; + + $updateResponse = $this->app->handle(Request::create( + 'PATCH', + '/api/v1/repo-configs/' . $id, + body: ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#000000'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + self::assertSame(200, $updateResponse->status); + self::assertSame('#000000', $updateResponse->body()['repo']['color']); + + $deleteResponse = $this->app->handle(Request::create( + 'DELETE', + '/api/v1/repo-configs/' . $id, + headers: ['X-Admin-Key' => 'admin-secret'] + )); + self::assertSame(200, $deleteResponse->status); + + $listResponse = $this->app->handle(Request::create('GET', '/api/v1/repo-configs')); + self::assertSame([], array_filter( + $listResponse->body()['repos'], + static fn (array $r): bool => (int) $r['id'] === (int) $id + )); + } + /** * Der SSE-Endpoint (F13) ist wie die anderen GET-Endpunkte oeffentlich und liefert eine * Stream-Antwort - der eigentliche Polling-Loop wird hier bewusst NICHT ausgefuehrt diff --git a/api/tests/Unit/ConfigTest.php b/api/tests/Unit/ConfigTest.php index ee995ac..6941951 100644 --- a/api/tests/Unit/ConfigTest.php +++ b/api/tests/Unit/ConfigTest.php @@ -14,8 +14,6 @@ final class ConfigTest extends TestCase $config = new Config([]); self::assertSame('https://codeberg.org/api/v1', $config->codebergApiBase()); - self::assertSame('fds2610', $config->codebergOwner()); - self::assertSame('monitor', $config->codebergRepo()); self::assertNull($config->codebergToken()); self::assertNull($config->adminApiKey()); self::assertSame(8, $config->deskCount()); @@ -43,8 +41,6 @@ final class ConfigTest extends TestCase { $config = new Config([ 'CODEBERG_API_BASE' => 'https://example.org/api/v1/', - 'CODEBERG_OWNER' => 'someone', - 'CODEBERG_REPO' => 'somerepo', 'CODEBERG_TOKEN' => 'tok', 'MONITOR_ADMIN_API_KEY' => 'secret', 'MONITOR_DESK_COUNT' => '3', @@ -53,8 +49,6 @@ final class ConfigTest extends TestCase ]); self::assertSame('https://example.org/api/v1', $config->codebergApiBase()); - self::assertSame('someone', $config->codebergOwner()); - self::assertSame('somerepo', $config->codebergRepo()); self::assertSame('tok', $config->codebergToken()); self::assertSame('secret', $config->adminApiKey()); self::assertSame(3, $config->deskCount()); diff --git a/api/tests/Unit/Controller/RepoConfigControllerTest.php b/api/tests/Unit/Controller/RepoConfigControllerTest.php new file mode 100644 index 0000000..9c10bb5 --- /dev/null +++ b/api/tests/Unit/Controller/RepoConfigControllerTest.php @@ -0,0 +1,82 @@ +pdo()->exec('DELETE FROM repo_configs'); + $this->controller = new RepoConfigController(new RepoConfigRepository($db)); + } + + public function testIndexReturnsEmptyListInitially(): void + { + $response = $this->controller->index(Request::create('GET', '/api/v1/repo-configs')); + + self::assertSame(200, $response->status); + self::assertSame([], $response->body()['repos']); + } + + public function testCreateReturns201WithNewRepo(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/repo-configs', body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04', + ])); + + self::assertSame(201, $response->status); + self::assertSame('fds2610', $response->body()['repo']['owner']); + } + + public function testCreateRejectsInvalidColorWith422(): void + { + $response = $this->controller->create(Request::create('POST', '/api/v1/repo-configs', body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => 'nope', + ])); + + self::assertSame(422, $response->status); + } + + public function testUpdateReturnsUpdatedRepo(): void + { + $created = $this->controller->create(Request::create('POST', '/api/v1/repo-configs', body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04', + ]))->body()['repo']; + + $response = $this->controller->update( + Request::create('PATCH', '/api/v1/repo-configs/' . $created['id'], body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#000000', + ]), + ['id' => (string) $created['id']] + ); + + self::assertSame(200, $response->status); + self::assertSame('#000000', $response->body()['repo']['color']); + } + + public function testDeleteReturnsDeletedTrue(): void + { + $created = $this->controller->create(Request::create('POST', '/api/v1/repo-configs', body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04', + ]))->body()['repo']; + + $response = $this->controller->delete( + Request::create('DELETE', '/api/v1/repo-configs/' . $created['id']), + ['id' => (string) $created['id']] + ); + + self::assertSame(200, $response->status); + self::assertTrue($response->body()['deleted']); + } +} diff --git a/api/tests/Unit/Repository/RepoConfigRepositoryTest.php b/api/tests/Unit/Repository/RepoConfigRepositoryTest.php new file mode 100644 index 0000000..9d25983 --- /dev/null +++ b/api/tests/Unit/Repository/RepoConfigRepositoryTest.php @@ -0,0 +1,99 @@ +pdo()->exec('DELETE FROM repo_configs'); + $this->repository = new RepoConfigRepository($db); + } + + public function testAllIsEmptyWithoutConfiguredProjects(): void + { + self::assertSame([], $this->repository->all()); + } + + public function testCreateNormalizesColorAndAppendsAtEndOfSortOrder(): void + { + $first = $this->repository->create(['owner' => 'fds2610', 'repo' => 'monitor', 'color' => 'fbca04']); + $second = $this->repository->create(['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28A745']); + + self::assertSame('#fbca04', $first['color']); + self::assertSame('#28A745', $second['color']); + self::assertSame([$first['id'], $second['id']], array_map( + static fn (array $r): int => (int) $r['id'], + $this->repository->all() + )); + } + + public function testCreateRejectsMissingOwnerOrRepo(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->create(['owner' => '', 'repo' => 'monitor', 'color' => '#fbca04']); + } + + public function testCreateRejectsInvalidColor(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->repository->create(['owner' => 'fds2610', 'repo' => 'monitor', 'color' => 'nicht-hex']); + } + + public function testCreateRejectsDuplicateOwnerRepo(): void + { + $this->repository->create(['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04']); + + $this->expectException(InvalidArgumentException::class); + $this->repository->create(['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#000000']); + } + + public function testUpdateChangesFields(): void + { + $repo = $this->repository->create(['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04']); + + $updated = $this->repository->update((int) $repo['id'], [ + 'owner' => 'fds2610', + 'repo' => 'monitor', + 'color' => '#000000', + ]); + + self::assertSame('#000000', $updated['color']); + } + + public function testUpdateUnknownIdThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->repository->update(999, ['owner' => 'a', 'repo' => 'b', 'color' => '#000000']); + } + + public function testDeleteRemovesEntry(): void + { + $repo = $this->repository->create(['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04']); + + $this->repository->delete((int) $repo['id']); + + self::assertSame([], $this->repository->all()); + } + + public function testDeleteUnknownIdThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->repository->delete(999); + } +} diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index 845b067..265bb86 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Monitor\Api\Tests\Sync; use Monitor\Api\Db\Database; +use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergClient; use Monitor\Api\Sync\CodebergSyncService; @@ -12,23 +13,52 @@ use PHPUnit\Framework\TestCase; final class FakeCodebergClient implements CodebergClient { - /** @param array> $issuesByState */ - public function __construct(private readonly array $issuesByState) + /** @param array>> $issuesByRepoAndState */ + public function __construct(private readonly array $issuesByRepoAndState) { } public function listIssues(string $owner, string $repo, string $state): array { - return $this->issuesByState[$state] ?? []; + return $this->issuesByRepoAndState[$owner . '/' . $repo][$state] ?? []; } } final class CodebergSyncServiceTest extends TestCase { - public function testSyncCreatesBacklogAndDoneTickets(): void + private const MONITOR_REPO = ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04']; + private const MONITOR_SLUG = 'fds2610/monitor'; + + /** @param array> $issuesByState */ + private static function clientForMonitor(array $issuesByState): FakeCodebergClient + { + return new FakeCodebergClient([self::MONITOR_SLUG => $issuesByState]); + } + + /** + * Baut ein von der Produktions-Seed-Liste (003_repo_configs.sql) unabhaengiges + * RepoConfigRepository mit genau den fuer den Test benoetigten Projekten - ein eigenes + * In-Memory-DB reicht, da CodebergSyncService Tickets und Projektliste ueber getrennte + * Repositories anspricht. + * + * @param array $repos + */ + private static function repoConfigsFor(array $repos): RepoConfigRepository + { + $db = Database::inMemory(); + $db->pdo()->exec('DELETE FROM repo_configs'); + $repoConfigs = new RepoConfigRepository($db); + foreach ($repos as $repo) { + $repoConfigs->create($repo); + } + + return $repoConfigs; + } + + public function testSyncCreatesBacklogAndDoneTicketsColoredByProject(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]], ], @@ -37,7 +67,7 @@ final class CodebergSyncServiceTest extends TestCase ], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); $backlog = $tickets->all('backlog'); $done = $tickets->all('done'); @@ -45,46 +75,64 @@ final class CodebergSyncServiceTest extends TestCase self::assertCount(1, $backlog); self::assertSame('Offenes Issue', $backlog[0]['title']); self::assertSame('bug', $backlog[0]['type']); - self::assertSame('#ff0000', $backlog[0]['color']); + self::assertSame('#fbca04', $backlog[0]['color']); + self::assertSame(self::MONITOR_SLUG, $backlog[0]['repo']); self::assertSame('codeberg', $backlog[0]['source']); self::assertCount(1, $done); self::assertSame('Geschlossenes Issue', $done[0]['title']); self::assertNull($done[0]['type']); - self::assertNull($done[0]['color']); + self::assertSame('#fbca04', $done[0]['color']); } - public function testSyncNormalizesLabelColorRegardlessOfLeadingHash(): void + public function testSyncKeepsIssuesFromDifferentProjectsSeparateAndColorsThemPerProject(): void { $tickets = new TicketRepository(Database::inMemory()); $client = new FakeCodebergClient([ - 'open' => [ - ['number' => 1, 'title' => 'Mit Raute', 'body' => null, 'labels' => [['name' => 'bug', 'color' => '#00ff00']]], + 'fds2610/monitor' => [ + 'open' => [['number' => 1, 'title' => 'Monitor-Issue 1', 'body' => null, 'labels' => []]], + 'closed' => [], + ], + 'fds2610/Steuerkram' => [ + 'open' => [['number' => 1, 'title' => 'Steuerkram-Issue 1', 'body' => null, 'labels' => []]], + 'closed' => [], ], - 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([ + self::MONITOR_REPO, + ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], + ])))->sync(); - self::assertSame('#00ff00', $tickets->all('backlog')[0]['color']); + $backlog = $tickets->all('backlog'); + self::assertCount(2, $backlog, 'gleiche Issue-Nummer aus verschiedenen Projekten darf nicht zu einem Ticket verschmelzen'); + + $monitorTicket = $tickets->findByExternalRef('1', 'fds2610/monitor'); + $steuerkramTicket = $tickets->findByExternalRef('1', 'fds2610/Steuerkram'); + + self::assertSame('Monitor-Issue 1', $monitorTicket['title']); + self::assertSame('#fbca04', $monitorTicket['color']); + + self::assertSame('Steuerkram-Issue 1', $steuerkramTicket['title']); + self::assertSame('#28a745', $steuerkramTicket['color']); } public function testSyncDoesNotResetManuallyMovedTicket(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); $service->sync(); - $ticket = $tickets->findByExternalRef('1'); + $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); $tickets->update((int) $ticket['id'], ['column' => 'qa']); $service->sync(); - $stillQa = $tickets->findByExternalRef('1'); + $stillQa = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertSame('qa', $stillQa['column_name']); } @@ -102,65 +150,65 @@ final class CodebergSyncServiceTest extends TestCase public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]], ], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); - $ticket = $tickets->findByExternalRef('1'); + $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertSame($expectedColumn, $ticket['column_name']); } public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]], ], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); $service->sync(); - self::assertSame('bereit', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('bereit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]], ], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); - self::assertSame('in_arbeit', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); $service->sync(); - self::assertSame('testing', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('testing', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [], 'closed' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); - self::assertSame('done', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('done', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } } diff --git a/docker-compose.yml b/docker-compose.yml index e91d02c..94807cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,8 +22,6 @@ services: MONITOR_SSE_POLL_INTERVAL: 2 MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested CODEBERG_API_BASE: https://codeberg.org/api/v1 - CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} - CODEBERG_REPO: ${CODEBERG_REPO:-monitor} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} # Secret des Codeberg-Webhooks. Ohne diesen Wert weist POST /api/v1/webhooks/codeberg # jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die @@ -57,8 +55,6 @@ services: MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600} MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested CODEBERG_API_BASE: https://codeberg.org/api/v1 - CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} - CODEBERG_REPO: ${CODEBERG_REPO:-monitor} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} command: php bin/sync-codeberg.php --watch diff --git a/html/css/office.css b/html/css/office.css index 63d4db1..9023ad9 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -569,3 +569,106 @@ header.office-header h1 { background: #10131a; border-radius: 6px; } + +/* Projekte-Modal (Issue #27: Klick auf den PO -> Projekte des Codeberg-Sync verwalten) */ + +.office-modal-error { + margin: 0 0 10px; + padding: 8px 12px; + border-radius: 6px; + background: #fef2f2; + border: 1px solid #fca5a5; + color: #991b1b; + font-size: 0.9rem; +} + +.repo-config-item { + display: flex; + align-items: center; + gap: 10px; +} + +.repo-config-swatch { + flex: none; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid rgba(15, 23, 42, 0.15); +} + +.repo-config-slug { + flex: 1; + font-weight: 600; + color: #1f2937; +} + +.repo-config-actions { + display: flex; + gap: 4px; + flex: none; +} + +.repo-config-form { + flex-wrap: wrap; +} + +.repo-config-form-fields { + display: flex; + flex: 1; + gap: 8px; + min-width: 0; +} + +.repo-config-form-fields input[type="text"] { + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 0.9rem; +} + +.repo-config-form-fields input[type="color"] { + flex: none; + width: 36px; + height: 32px; + padding: 2px; + border: 1px solid #cbd5e1; + border-radius: 6px; + cursor: pointer; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #475569; + cursor: pointer; +} + +.icon-button:hover { + background: #f1f5f9; + border-color: #e2e8f0; + color: #1f2937; +} + +.icon-button-svg { + width: 18px; + height: 18px; +} + +.repo-config-add-button { + margin-top: 8px; + color: #16a34a; +} + +.repo-config-add-button:hover { + background: #f0fdf4; + border-color: #bbf7d0; + color: #15803d; +} diff --git a/html/js/office-api.js b/html/js/office-api.js index aa2c284..efd6477 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -60,3 +60,75 @@ export async function fetchAgents() { export function streamUrl() { return `${apiBase()}/stream`; } + +// --------------------------------------------------------------------------------------- +// Projekte des Codeberg-Sync (Issue #27: Klick auf den PO oeffnet ein Modal, um Projekte +// hinzuzufuegen/zu bearbeiten/zu loeschen). Schreibende Endpunkte verlangen X-Admin-Key +// (siehe API.md "Authentifizierung") - anders als der Agenten-/Ticket-API-Key gibt es dafuer +// keine Registrierung im Browser, der Admin-Key wird daher einmalig manuell eingegeben und +// im lokalen Speicher gemerkt (siehe office-render.js openRepoConfigModal()). +const ADMIN_KEY_STORAGE_KEY = "monitor.adminKey"; + +export function getStoredAdminKey() { + try { + return window.localStorage.getItem(ADMIN_KEY_STORAGE_KEY) || ""; + } catch { + // localStorage kann im Privat-/Inkognito-Modus blockiert sein - dann muss der Admin-Key + // bei jeder Aenderung erneut eingegeben werden, kein Hard-Fail. + return ""; + } +} + +export function setStoredAdminKey(key) { + try { + if (key) { + window.localStorage.setItem(ADMIN_KEY_STORAGE_KEY, key); + } else { + window.localStorage.removeItem(ADMIN_KEY_STORAGE_KEY); + } + } catch { + // s.o. + } +} + +async function adminJson(method, path, body, adminKey) { + const response = await fetch(`${apiBase()}${path}`, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Admin-Key": adminKey, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (response.status === 401) { + const error = new Error("Admin-Key ungueltig oder fehlend."); + error.isAuthError = true; + throw error; + } + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw new Error(payload?.error || `${method} ${path} fehlgeschlagen: HTTP ${response.status}`); + } + return response.status === 204 ? null : response.json(); +} + +export async function fetchRepoConfigs() { + const { repos } = await getJson("/repo-configs"); + return repos; +} + +export async function createRepoConfig(data, adminKey) { + const { repo } = await adminJson("POST", "/repo-configs", data, adminKey); + return repo; +} + +export async function updateRepoConfig(id, data, adminKey) { + const { repo } = await adminJson("PATCH", `/repo-configs/${encodeURIComponent(id)}`, data, adminKey); + return repo; +} + +export async function deleteRepoConfig(id, adminKey) { + await adminJson("DELETE", `/repo-configs/${encodeURIComponent(id)}`, undefined, adminKey); +} diff --git a/html/js/office-modal.js b/html/js/office-modal.js index abf8b81..e73ee36 100644 --- a/html/js/office-modal.js +++ b/html/js/office-modal.js @@ -5,6 +5,13 @@ let dialogEl = null; +// Erhoeht sich bei jedem openModal()-Aufruf (fuer JEDEN Modal-Typ) - alle Modals teilen sich +// dasselbe -Element (ensureDialog() erzeugt es nur einmal). Ein interaktives Modal mit +// eigenem Server-Rundtrip (z.B. das Projekte-Modal, Issue #27) kann per isCurrentModal() vor +// einem verspaeteten Repaint pruefen, ob der Nutzer inzwischen ein anderes Modal geoeffnet hat - +// sonst wuerde eine spaet aufloesende Promise dessen Inhalt ueberschreiben. +let openToken = 0; + function ensureDialog() { if (dialogEl) { return dialogEl; @@ -30,8 +37,12 @@ function ensureDialog() { * * @param {{title: string}} content * @param {(body: HTMLElement, content: object) => void} renderBody + * @returns {number} Token fuer isCurrentModal() - siehe openToken oben. */ export function openModal(content, renderBody) { + openToken += 1; + const token = openToken; + const dialog = ensureDialog(); dialog.querySelector(".office-modal-title").textContent = content.title; @@ -45,6 +56,13 @@ export function openModal(content, renderBody) { // Fallback fuer Umgebungen ohne -Unterstuetzung (z.B. sehr alte Browser). dialog.setAttribute("open", ""); } + + return token; +} + +/** Siehe openToken/openModal() - true, solange kein anderer openModal()-Aufruf dazwischenkam. */ +export function isCurrentModal(token) { + return token === openToken; } export function closeModal() { diff --git a/html/js/office-render.js b/html/js/office-render.js index d6a15eb..e01f790 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -49,9 +49,21 @@ import { DEMO_EVENTS, DEMO_KPI_PLACEHOLDER, } from "./demo-data.js"; -import { fetchTickets, fetchEvents, fetchSystems, fetchKpis, fetchAgents } from "./office-api.js"; +import { + fetchTickets, + fetchEvents, + fetchSystems, + fetchKpis, + fetchAgents, + fetchRepoConfigs, + createRepoConfig, + updateRepoConfig, + deleteRepoConfig, + getStoredAdminKey, + setStoredAdminKey, +} from "./office-api.js"; import { startRealtimeUpdates } from "./office-realtime.js"; -import { openModal } from "./office-modal.js"; +import { openModal, isCurrentModal } from "./office-modal.js"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -521,12 +533,20 @@ function renderDesk({ deskIndex, agent }) { * @param {Array} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1). * @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer * API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19). + * @param {(() => void)|null} onPoClick - oeffnet das Projekte-Modal (Issue #27); nur gesetzt + * bei erreichbarer API, da das Modal echte Daten braucht und im Demo-Fallback ohne + * funktionierende API nichts zu konfigurieren waere. */ -function renderDesks(desks, poAgent) { +function renderDesks(desks, poAgent, onPoClick = null) { const g = el("g", { class: "desks" }); desks.forEach((desk) => g.appendChild(renderDesk(desk))); const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w); poDeskGroup.classList.add("po-desk"); + if (onPoClick) { + poDeskGroup.classList.add("clickable"); + poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten"); + poDeskGroup.addEventListener("click", onPoClick); + } g.appendChild(poDeskGroup); return g; } @@ -947,6 +967,291 @@ function emptyMessage(message) { return p; } +// --------------------------------------------------------------------------------------- +// Projekte-Modal (Issue #27: Klick auf den PO -> Codeberg-Projekte des Sync verwalten) +// +// Anders als die uebrigen Modals (reine Anzeige eines einmal gebauten content-Objekts) ist +// dieses Modal interaktiv (Hinzufuegen/Bearbeiten/Loeschen mit Server-Rundtrip) und muss sich +// nach jeder Aenderung selbst neu zeichnen, ohne das zu schliessen. openModal() ruft +// den renderBody-Callback dafuer nur einmal mit dem echten Body-Element auf; das Element wird +// hier in repoModalBodyEl gemerkt und fuer spaetere Refreshes wiederverwendet. +// --------------------------------------------------------------------------------------- + +const REPO_CONFIG_ICONS = { + add: "M12 5v14M5 12h14", + edit: "M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z", + delete: "M4 7h16M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7m2 0-.8 12.1a2 2 0 0 1-2 1.9H9.8a2 2 0 0 1-2-1.9L7 7", + confirm: "M20 6 9 17l-5-5", + cancel: "M18 6 6 18M6 6l12 12", +}; + +let repoModalBodyEl = null; +let repoModalToken = null; +let repoModalRepos = []; +let repoModalMode = null; // null | "new" | +let repoModalError = ""; + +function iconButton(kind, title, onClick, extraClass = "") { + const button = document.createElement("button"); + button.type = "button"; + button.className = `icon-button ${extraClass}`.trim(); + button.title = title; + button.setAttribute("aria-label", title); + button.appendChild( + el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [ + el("path", { + d: REPO_CONFIG_ICONS[kind], + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + }), + ]) + ); + button.addEventListener("click", onClick); + return button; +} + +function openRepoConfigModal() { + repoModalRepos = []; + repoModalMode = null; + repoModalError = ""; + repoModalToken = openModal({ title: "Projekte" }, (body) => { + repoModalBodyEl = body; + renderRepoConfigModal(); + }); + loadRepoConfigModal(repoModalToken); +} + +async function loadRepoConfigModal(token) { + let repos = null; + let error = ""; + try { + repos = await fetchRepoConfigs(); + } catch { + error = "Projekte konnten nicht geladen werden."; + } + + if (!isCurrentModal(token)) { + return; + } + if (repos !== null) { + repoModalRepos = repos; + } + repoModalError = error; + renderRepoConfigModal(); +} + +function renderRepoConfigModal() { + const body = repoModalBodyEl; + if (!body) return; + body.replaceChildren(); + + if (repoModalError) { + const errorEl = document.createElement("p"); + errorEl.className = "office-modal-error"; + errorEl.textContent = repoModalError; + body.appendChild(errorEl); + } + + if (repoModalRepos.length === 0 && repoModalMode !== "new") { + body.appendChild(emptyMessage("Keine Projekte konfiguriert.")); + } else { + const list = document.createElement("ul"); + list.className = "office-modal-list repo-config-list"; + for (const repo of repoModalRepos) { + list.appendChild( + repoModalMode === repo.id ? renderRepoConfigForm(repo) : renderRepoConfigRow(repo) + ); + } + body.appendChild(list); + } + + if (repoModalMode === "new") { + let list = body.querySelector(".repo-config-list"); + if (!list) { + list = document.createElement("ul"); + list.className = "office-modal-list repo-config-list"; + body.appendChild(list); + } + list.appendChild(renderRepoConfigForm(null)); + } else { + const addButton = iconButton("add", "Projekt hinzufügen", () => { + repoModalMode = "new"; + renderRepoConfigModal(); + }); + addButton.classList.add("repo-config-add-button"); + body.appendChild(addButton); + } +} + +function renderRepoConfigRow(repo) { + const li = document.createElement("li"); + li.className = "office-modal-item repo-config-item"; + + const swatch = document.createElement("span"); + swatch.className = "repo-config-swatch"; + swatch.style.backgroundColor = repo.color; + li.appendChild(swatch); + + const label = document.createElement("span"); + label.className = "repo-config-slug"; + label.textContent = `${repo.owner}/${repo.repo}`; + li.appendChild(label); + + const actions = document.createElement("span"); + actions.className = "repo-config-actions"; + actions.appendChild( + iconButton("edit", "Projekt bearbeiten", () => { + repoModalMode = repo.id; + renderRepoConfigModal(); + }) + ); + actions.appendChild( + iconButton("delete", "Projekt löschen", () => handleRepoConfigDelete(repo)) + ); + li.appendChild(actions); + + return li; +} + +function renderRepoConfigForm(repo) { + const li = document.createElement("li"); + li.className = "office-modal-item repo-config-item repo-config-form"; + + const ownerInput = document.createElement("input"); + ownerInput.type = "text"; + ownerInput.placeholder = "Owner, z. B. fds2610"; + ownerInput.value = repo?.owner ?? ""; + ownerInput.setAttribute("aria-label", "Owner"); + + const repoInput = document.createElement("input"); + repoInput.type = "text"; + repoInput.placeholder = "Repo, z. B. monitor"; + repoInput.value = repo?.repo ?? ""; + repoInput.setAttribute("aria-label", "Repo-Name"); + + const colorInput = document.createElement("input"); + colorInput.type = "color"; + colorInput.value = repo?.color ?? "#999999"; + colorInput.setAttribute("aria-label", "Farbe"); + colorInput.title = "Farbe"; + + const fields = document.createElement("span"); + fields.className = "repo-config-form-fields"; + fields.append(ownerInput, repoInput, colorInput); + li.appendChild(fields); + + const actions = document.createElement("span"); + actions.className = "repo-config-actions"; + + const save = () => + handleRepoConfigSave(repo, { + owner: ownerInput.value.trim(), + repo: repoInput.value.trim(), + color: colorInput.value, + }); + + actions.appendChild(iconButton("confirm", "Speichern", save)); + actions.appendChild( + iconButton("cancel", "Abbrechen", () => { + repoModalMode = null; + renderRepoConfigModal(); + }) + ); + li.appendChild(actions); + + // Enter in einem der Textfelder wuerde sonst das
ueber dessen + // regulaeren Submit-Button (den Schliessen-"×" im Modal-Header) einreichen und das ganze + // Modal schliessen, statt nur dieses Formular zu speichern. + const saveOnEnter = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + save(); + } + }; + ownerInput.addEventListener("keydown", saveOnEnter); + repoInput.addEventListener("keydown", saveOnEnter); + + window.setTimeout(() => ownerInput.focus(), 0); + + return li; +} + +async function handleRepoConfigSave(existing, data) { + const token = repoModalToken; + if (!data.owner || !data.repo) { + repoModalError = 'Felder "Owner" und "Repo" sind erforderlich.'; + renderRepoConfigModal(); + return; + } + + const ok = await withAdminKey((adminKey) => + existing ? updateRepoConfig(existing.id, data, adminKey) : createRepoConfig(data, adminKey) + ); + if (!isCurrentModal(token)) { + return; + } + if (!ok) { + renderRepoConfigModal(); + return; + } + + repoModalMode = null; + await loadRepoConfigModal(token); +} + +async function handleRepoConfigDelete(repo) { + const token = repoModalToken; + if (!window.confirm(`Projekt "${repo.owner}/${repo.repo}" wirklich entfernen?`)) { + return; + } + + const ok = await withAdminKey((adminKey) => deleteRepoConfig(repo.id, adminKey)); + if (!isCurrentModal(token)) { + return; + } + if (!ok) { + renderRepoConfigModal(); + return; + } + + await loadRepoConfigModal(token); +} + +/** + * Fuehrt eine schreibende Aktion mit dem Admin-Key aus (aus dem lokalen Speicher oder frisch + * per Prompt erfragt, siehe office-api.js). Bei 401 wird der gemerkte Key verworfen, damit + * der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden. + * + * @param {(adminKey: string) => Promise} action + * @returns {Promise} + */ +async function withAdminKey(action) { + let adminKey = getStoredAdminKey(); + if (!adminKey) { + adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || ""; + if (!adminKey) { + return false; + } + } + + try { + await action(adminKey); + setStoredAdminKey(adminKey); + return true; + } catch (err) { + if (err && err.isAuthError) { + setStoredAdminKey(""); + repoModalError = "Admin-Key ungültig."; + } else { + repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen."; + } + return false; + } +} + // --------------------------------------------------------------------------------------- // Datenbeschaffung + Orchestrierung // --------------------------------------------------------------------------------------- @@ -1042,7 +1347,7 @@ export function renderOfficeScene(container, data = {}) { const deskLayout = buildDeskLayout(normalizedDeskAgents(agents)); const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null; - const desksGroup = renderDesks(deskLayout, poDeskAgent); + const desksGroup = renderDesks(deskLayout, poDeskAgent, isLive ? openRepoConfigModal : null); const existingDesks = svg.querySelector(".desks"); if (existingDesks) { existingDesks.replaceWith(desksGroup); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 989134c..ca75d02 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -147,7 +147,9 @@ export function groupTicketsByColumn(tickets = []) { /** * Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter * Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per - * Codeberg-Sync angelegt wurde (F4). + * Codeberg-Sync angelegt wurde (F4). Seit F26 (Tickets aus mehreren Codeberg-Projekten) + * traegt jedes Ticket sein Quell-Repo ("owner/name") selbst in ticket.repo; opts dient nur + * noch als Fallback fuer aeltere Tickets ohne dieses Feld. * * @param {string} columnKey * @param {Array} tickets @@ -167,7 +169,7 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) { color: ticket.color || null, link: ticket.source === "codeberg" && ticket.external_ref - ? `https://codeberg.org/${codebergOwner}/${codebergRepo}/issues/${ticket.external_ref}` + ? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/issues/${ticket.external_ref}` : null, })), }; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 05715c0..ca77381 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -174,6 +174,15 @@ test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () => assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7"); }); +test("buildTicketModalContent baut den Link aus dem repo-Feld des Tickets, nicht aus opts (mehrere Codeberg-Projekte, F26)", () => { + const content = buildTicketModalContent("backlog", [ + { id: 1, title: "A", source: "codeberg", external_ref: "3", repo: "fds2610/Steuerkram", color: "#28a745" }, + { id: 2, title: "B", source: "codeberg", external_ref: "9", repo: "fds2610/nc-gantt", color: "#0366d6" }, + ]); + assert.equal(content.items[0].link, "https://codeberg.org/fds2610/Steuerkram/issues/3"); + assert.equal(content.items[1].link, "https://codeberg.org/fds2610/nc-gantt/issues/9"); +}); + // --------------------------------------------------------------------------------------- // Phase 4 - Monitor-Wand live // ---------------------------------------------------------------------------------------