Codeberg-Projekte des Sync ueber ein Modal am PO konfigurierbar machen

Klick auf den PO oeffnet ein Modal, in dem sich die Projekte (Owner/Repo +
Farbe), aus denen der Codeberg-Sync liest, hinzufuegen, bearbeiten und
loeschen lassen - Icon-Schaltflaechen mit Tooltip statt Textbuttons. Die
Projektliste war bisher eine Umgebungsvariable (CODEBERG_REPOS, Issue #26)
und wandert dafuer in eine neue Tabelle repo_configs (Migration
003_repo_configs.sql, geseedet mit den bisherigen drei Default-Projekten);
CodebergSyncService liest sie ueber RepoConfigRepository bei jedem Lauf
frisch ein, damit eine Aenderung im laufenden --watch-Prozess ohne Neustart
wirkt. Neue Endpunkte GET/POST/PATCH/DELETE /api/v1/repo-configs, Schreiben
hinter X-Admin-Key wie die Agenten-Registrierung; das Frontend fragt den Key
bei der ersten Aenderung per Prompt ab und merkt ihn sich im lokalen
Speicher. office-modal.js bekommt dafuer einen Oeffnen-Token
(isCurrentModal()), damit ein verspaetet aufloesender Request nach dem
Oeffnen eines anderen Modals nicht dessen Inhalt ueberschreibt.

Schliesst #27
This commit is contained in:
fds2610-bot 2026-07-30 17:53:39 +02:00
parent 3aaf6aea54
commit d72c945a61
20 changed files with 1057 additions and 109 deletions

View file

@ -11,12 +11,13 @@ MONITOR_ADMIN_API_KEY=change-me
# die relative URL /api/v1 verwendet).
MONITOR_API_BASE=http://localhost:8091/api/v1
# Codeberg-Projekte, aus denen Backlog/Done per Sync-Skript befuellt werden. Leer lassen fuer
# die Default-Projekte (fds2610/monitor gelb, fds2610/Steuerkram gruen, fds2610/nc-gantt
# blau, siehe Config::codebergRepos()). Eigenes Format bei Bedarf:
# "owner/repo:farbe,owner/repo:farbe,..." (Farbe mit oder ohne fuehrendes '#'), z.B.
# CODEBERG_REPOS=fds2610/monitor:fbca04,fds2610/Steuerkram:28a745,fds2610/nc-gantt:0366d6
CODEBERG_REPOS=
# 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=

33
API.md
View file

@ -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
@ -268,13 +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 den konfigurierten
Codeberg-Projekten (`CODEBERG_REPOS`, siehe `.env.example` - ohne Override die drei
Default-Projekte `fds2610/monitor` (gelb), `fds2610/Steuerkram` (gruen) und
`fds2610/nc-gantt` (blau)) und gleicht sie je Projekt ueber `repo` + `external_ref`
(= Issue-Nummer) mit den Tickets ab:
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`.
@ -319,8 +338,8 @@ 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 **aller** konfigurierten Projekte
(`CODEBERG_REPOS`), nicht nur das des ausloesenden Repos. Vom Aendern des Issues bis zum
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

View file

@ -21,9 +21,13 @@ 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 mehrerer konfigurierter Codeberg-Projekte (`CODEBERG_REPOS`, je Projekt mit
eigener Ticket-Farbe) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein
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.

View file

@ -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,7 +20,7 @@ $db = Database::forFile($config->dbPath());
$service = new CodebergSyncService(
new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
new TicketRepository($db),
$config->codebergRepos(),
new RepoConfigRepository($db),
);
// Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README).

View file

@ -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(

View file

@ -54,51 +54,6 @@ final class Config
return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/');
}
/**
* Default-Projekte fuer den Codeberg-Sync (Issue #26: "Tickets auch von anderen
* Codeberg-Projekten anzeigen"). Jedes Projekt bekommt eine feste Farbe, an der sich
* Tickets auf dem Board unabhaengig vom Issue-Label ihrem Ursprungsprojekt zuordnen
* lassen.
*/
private const DEFAULT_REPOS = [
['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'],
];
/**
* Codeberg-Projekte, aus denen der Sync Tickets liest. Override per
* CODEBERG_REPOS="owner/repo:farbe,owner/repo:farbe,..." (Farbe mit oder ohne
* fuehrendes "#"); ohne gesetzte/gueltige Variable gelten die drei Default-Projekte.
*
* @return array<int, array{owner: string, repo: string, color: string}>
*/
public function codebergRepos(): array
{
$raw = $this->get('CODEBERG_REPOS');
if ($raw === null) {
return self::DEFAULT_REPOS;
}
$repos = [];
foreach (explode(',', $raw) as $entry) {
$entry = trim($entry);
if ($entry === '') {
continue;
}
[$slug, $color] = array_pad(explode(':', $entry, 2), 2, null);
[$owner, $repo] = array_pad(explode('/', (string) $slug, 2), 2, null);
if ($owner === null || $owner === '' || $repo === null || $repo === '' || $color === null || $color === '') {
continue;
}
$repos[] = ['owner' => $owner, 'repo' => $repo, 'color' => '#' . ltrim($color, '#')];
}
return $repos === [] ? self::DEFAULT_REPOS : $repos;
}
public function codebergToken(): ?string
{
return $this->get('CODEBERG_TOKEN');

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\RepoConfigRepository;
final class RepoConfigController
{
public function __construct(private readonly RepoConfigRepository $repoConfigs)
{
}
public function index(Request $request): Response
{
return Response::json(['repos' => $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<string, string> $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<string, string> $params */
public function delete(Request $request, array $params): Response
{
$this->repoConfigs->delete((int) $params['id']);
return Response::json(['deleted' => true]);
}
}

View file

@ -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'));

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
/**
* Verwaltet die Codeberg-Projekte, aus denen CodebergSyncService liest (Issue #27: vorher
* CODEBERG_REPOS-Umgebungsvariable, siehe 003_repo_configs.sql). Der Sync-Dauerlaeufer ruft
* all() bei jedem Lauf frisch ab, damit eine Aenderung ueber das Frontend-Modal ohne
* Neustart des sync-Containers wirkt.
*/
final class RepoConfigRepository
{
public function __construct(private readonly Database $db)
{
}
/** @return array<int, array{owner: string, repo: string, color: string}> */
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<string, mixed>|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<string, mixed> $data @return array<string, mixed> */
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<string, mixed> $data @return array<string, mixed> */
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<string, mixed> $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'];
}
}

View file

@ -4,16 +4,17 @@ declare(strict_types=1);
namespace Monitor\Api\Sync;
use Monitor\Api\Repository\RepoConfigRepository;
use Monitor\Api\Repository\TicketRepository;
/**
* F4/F26: Liest offene und geschlossene Issues aus mehreren konfigurierten Codeberg-Projekten
* (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.
* (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
{
@ -27,17 +28,21 @@ final class CodebergSyncService
'automate' => 'bereit',
];
/** @param array<int, array{owner: string, repo: string, color: string}> $repos */
public function __construct(
private readonly CodebergClient $client,
private readonly TicketRepository $tickets,
private readonly array $repos,
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->repos as $repoConfig) {
foreach ($this->repoConfigs->all() as $repoConfig) {
$this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
}
}

View file

@ -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

View file

@ -14,11 +14,6 @@ final class ConfigTest extends TestCase
$config = new Config([]);
self::assertSame('https://codeberg.org/api/v1', $config->codebergApiBase());
self::assertSame([
['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'],
], $config->codebergRepos());
self::assertNull($config->codebergToken());
self::assertNull($config->adminApiKey());
self::assertSame(8, $config->deskCount());
@ -46,7 +41,6 @@ final class ConfigTest extends TestCase
{
$config = new Config([
'CODEBERG_API_BASE' => 'https://example.org/api/v1/',
'CODEBERG_REPOS' => 'someone/somerepo:ff00aa, other/thing:00ff00 ,malformed',
'CODEBERG_TOKEN' => 'tok',
'MONITOR_ADMIN_API_KEY' => 'secret',
'MONITOR_DESK_COUNT' => '3',
@ -55,25 +49,10 @@ final class ConfigTest extends TestCase
]);
self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
self::assertSame([
['owner' => 'someone', 'repo' => 'somerepo', 'color' => '#ff00aa'],
['owner' => 'other', 'repo' => 'thing', 'color' => '#00ff00'],
], $config->codebergRepos());
self::assertSame('tok', $config->codebergToken());
self::assertSame('secret', $config->adminApiKey());
self::assertSame(3, $config->deskCount());
self::assertSame(5, $config->sseMaxIterations());
self::assertSame(1, $config->ssePollIntervalSeconds());
}
public function testCodebergReposFallsBackToDefaultsWhenOverrideIsUnusable(): void
{
$config = new Config(['CODEBERG_REPOS' => 'nur-ein-slug-ohne-doppelpunkt']);
self::assertSame([
['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'],
], $config->codebergRepos());
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\RepoConfigController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\RepoConfigRepository;
use PHPUnit\Framework\TestCase;
final class RepoConfigControllerTest extends TestCase
{
private RepoConfigController $controller;
protected function setUp(): void
{
$db = Database::inMemory();
$db->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']);
}
}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Repository\RepoConfigRepository;
use PHPUnit\Framework\TestCase;
final class RepoConfigRepositoryTest extends TestCase
{
private RepoConfigRepository $repository;
protected function setUp(): void
{
$db = Database::inMemory();
$db->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);
}
}

View file

@ -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;
@ -34,6 +35,26 @@ final class CodebergSyncServiceTest extends TestCase
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<int, array{owner: string, repo: string, color: string}> $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());
@ -46,7 +67,7 @@ final class CodebergSyncServiceTest extends TestCase
],
]);
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
(new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync();
$backlog = $tickets->all('backlog');
$done = $tickets->all('done');
@ -78,10 +99,10 @@ final class CodebergSyncServiceTest extends TestCase
],
]);
(new CodebergSyncService($client, $tickets, [
(new CodebergSyncService($client, $tickets, self::repoConfigsFor([
self::MONITOR_REPO,
['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'],
]))->sync();
])))->sync();
$backlog = $tickets->all('backlog');
self::assertCount(2, $backlog, 'gleiche Issue-Nummer aus verschiedenen Projekten darf nicht zu einem Ticket verschmelzen');
@ -103,7 +124,7 @@ final class CodebergSyncServiceTest extends TestCase
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
'closed' => [],
]);
$service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]);
$service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]));
$service->sync();
$ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
@ -136,7 +157,7 @@ final class CodebergSyncServiceTest extends TestCase
'closed' => [],
]);
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
(new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync();
$ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
self::assertSame($expectedColumn, $ticket['column_name']);
@ -151,7 +172,7 @@ final class CodebergSyncServiceTest extends TestCase
],
'closed' => [],
]);
$service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]);
$service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]));
$service->sync();
self::assertSame('bereit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
@ -162,7 +183,7 @@ final class CodebergSyncServiceTest extends TestCase
],
'closed' => [],
]);
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
(new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync();
self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
}
@ -176,7 +197,7 @@ final class CodebergSyncServiceTest extends TestCase
],
'closed' => [],
]);
$service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]);
$service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]));
$service->sync();
self::assertSame('testing', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
@ -186,7 +207,7 @@ final class CodebergSyncServiceTest extends TestCase
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]],
],
]);
(new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync();
(new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync();
self::assertSame('done', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
}

View file

@ -22,10 +22,6 @@ services:
MONITOR_SSE_POLL_INTERVAL: 2
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
CODEBERG_API_BASE: https://codeberg.org/api/v1
# Leer = Default-Projekte aus Config::codebergRepos() (fds2610/monitor, .../Steuerkram,
# .../nc-gantt, siehe API.md "Codeberg-Sync"). Override-Format:
# "owner/repo:farbe,owner/repo:farbe,...".
CODEBERG_REPOS: ${CODEBERG_REPOS:-}
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
@ -59,7 +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_REPOS: ${CODEBERG_REPOS:-}
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
command: php bin/sync-codeberg.php --watch

View file

@ -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;
}

View file

@ -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);
}

View file

@ -5,6 +5,13 @@
let dialogEl = null;
// Erhoeht sich bei jedem openModal()-Aufruf (fuer JEDEN Modal-Typ) - alle Modals teilen sich
// dasselbe <dialog>-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 <dialog>-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() {

View file

@ -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<object>} 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 <dialog> 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" | <id des bearbeiteten Projekts>
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 <form method="dialog"> 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<unknown>} action
* @returns {Promise<boolean>}
*/
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);