Merge pull request 'Codeberg-Projekte anzeigen und per Modal konfigurierbar machen (#26, #27)' (#28) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/28
This commit is contained in:
commit
1ad4f25777
25 changed files with 1208 additions and 114 deletions
10
.env.example
10
.env.example
|
|
@ -11,9 +11,13 @@ MONITOR_ADMIN_API_KEY=change-me
|
||||||
# die relative URL /api/v1 verwendet).
|
# die relative URL /api/v1 verwendet).
|
||||||
MONITOR_API_BASE=http://localhost:8091/api/v1
|
MONITOR_API_BASE=http://localhost:8091/api/v1
|
||||||
|
|
||||||
# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden
|
# Codeberg-Projekte, aus denen Backlog/Done per Sync-Skript befuellt werden, sind seit
|
||||||
CODEBERG_OWNER=fds2610
|
# Issue #27 keine Umgebungsvariable mehr, sondern ueber ein Modal im Frontend (Klick auf den
|
||||||
CODEBERG_REPO=monitor
|
# 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
|
# Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind - bei kleinem
|
||||||
# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert
|
# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert
|
||||||
CODEBERG_TOKEN=
|
CODEBERG_TOKEN=
|
||||||
|
|
|
||||||
58
API.md
58
API.md
|
|
@ -18,6 +18,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
|
||||||
- [Systeme](#systeme)
|
- [Systeme](#systeme)
|
||||||
- [KPIs](#kpis)
|
- [KPIs](#kpis)
|
||||||
- [Live-Updates (SSE)](#live-updates-sse)
|
- [Live-Updates (SSE)](#live-updates-sse)
|
||||||
|
- [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren)
|
||||||
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
|
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
|
||||||
- [Fehlerformat](#fehlerformat)
|
- [Fehlerformat](#fehlerformat)
|
||||||
|
|
||||||
|
|
@ -45,7 +46,7 @@ Zwei Schluesseltypen, beide statisch (F14):
|
||||||
|
|
||||||
| Header | Verwendet fuer | Herkunft |
|
| 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 |
|
| `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
|
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`.
|
Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
|
||||||
|
|
||||||
- `backlog`, `bereit`, `in_arbeit`, `testing` und `done` werden regelmaessig aus
|
- `backlog`, `bereit`, `in_arbeit`, `testing` und `done` werden regelmaessig aus
|
||||||
Codeberg-Issues befuellt (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und
|
Codeberg-Issues aus mehreren konfigurierten Projekten befuellt (siehe
|
||||||
`fertig` sind fuer zukuenftige Labels reserviert und ergeben sich ausschliesslich aus
|
[Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und `fertig` sind fuer zukuenftige
|
||||||
Agenten-Meldungen (F4).
|
Labels reserviert und ergeben sich ausschliesslich aus Agenten-Meldungen (F4).
|
||||||
- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten
|
- Ticket-Typ (statt Prioritaet) wird bei Codeberg-Tickets aus dem ersten Issue-Label
|
||||||
Issue-Label uebernommen (F4).
|
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;
|
- Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik;
|
||||||
die API liefert Tickets dafuer sortiert nach Spalte und Alter.
|
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).
|
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
|
### `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
|
Kommentar (`: heartbeat`) als Keep-Alive, damit Proxys die Verbindung nicht vorzeitig
|
||||||
schliessen.
|
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)
|
## Codeberg-Sync (Backlog/Done)
|
||||||
|
|
||||||
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus dem konfigurierten
|
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten
|
||||||
Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external_ref`
|
Codeberg-Projekten (siehe [Projekte](#projekte-codeberg-sync-konfigurieren)) und gleicht sie
|
||||||
(= Issue-Nummer) mit den Tickets ab:
|
je Projekt ueber `repo` + `external_ref` (= Issue-Nummer) mit den Tickets ab:
|
||||||
|
|
||||||
- Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`,
|
- Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`,
|
||||||
sonst `automate` → `bereit`, sonst `backlog`.
|
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`,
|
- 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
|
`testing`, `done`) ist - auf `qa`/`fertig` manuell verschobene Tickets werden nicht
|
||||||
zurueckgesetzt.
|
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
|
Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe
|
||||||
[docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit
|
[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,
|
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
|
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
|
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
|
Sekundentakt auf und laedt den aktuellen Stand **aller** konfigurierten Projekte (siehe
|
||||||
vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend).
|
[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
|
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
|
mitschickt: `X-Forgejo-Signature` (bzw. `X-Gitea-Signature`) enthaelt den HMAC-SHA256 des
|
||||||
|
|
|
||||||
14
README.md
14
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
|
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!".
|
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`).
|
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
|
- 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`
|
aus den Issues mehrerer konfigurierter Codeberg-Projekte (je Projekt mit eigener
|
||||||
Sekunden und, wenn ein Webhook eingerichtet ist, innerhalb von Sekunden nach jeder
|
Ticket-Farbe, siehe oben) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein
|
||||||
Issue-Änderung (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per
|
Webhook eingerichtet ist, innerhalb von Sekunden nach jeder Issue-Änderung
|
||||||
`CODEBERG_WEBHOOK_SECRET`). Siehe [API.md](API.md) Abschnitt „Codeberg-Sync" —
|
(`POST /api/v1/webhooks/codeberg`, HMAC-signiert per `CODEBERG_WEBHOOK_SECRET`). Siehe
|
||||||
dort steht auch die Einrichtung im Codeberg-Repo.
|
[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
|
- 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
|
eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit
|
||||||
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor
|
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use Monitor\Api\Config;
|
use Monitor\Api\Config;
|
||||||
use Monitor\Api\Db\Database;
|
use Monitor\Api\Db\Database;
|
||||||
|
use Monitor\Api\Repository\RepoConfigRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
use Monitor\Api\Sync\CodebergSyncService;
|
use Monitor\Api\Sync\CodebergSyncService;
|
||||||
use Monitor\Api\Sync\CurlCodebergClient;
|
use Monitor\Api\Sync\CurlCodebergClient;
|
||||||
|
|
@ -19,8 +20,7 @@ $db = Database::forFile($config->dbPath());
|
||||||
$service = new CodebergSyncService(
|
$service = new CodebergSyncService(
|
||||||
new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
|
new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
|
||||||
new TicketRepository($db),
|
new TicketRepository($db),
|
||||||
$config->codebergOwner(),
|
new RepoConfigRepository($db),
|
||||||
$config->codebergRepo(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README).
|
// Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README).
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use Monitor\Api\Auth\WebhookAuthenticator;
|
||||||
use Monitor\Api\Controller\AgentController;
|
use Monitor\Api\Controller\AgentController;
|
||||||
use Monitor\Api\Controller\EventController;
|
use Monitor\Api\Controller\EventController;
|
||||||
use Monitor\Api\Controller\KpiController;
|
use Monitor\Api\Controller\KpiController;
|
||||||
|
use Monitor\Api\Controller\RepoConfigController;
|
||||||
use Monitor\Api\Controller\StreamController;
|
use Monitor\Api\Controller\StreamController;
|
||||||
use Monitor\Api\Controller\SystemController;
|
use Monitor\Api\Controller\SystemController;
|
||||||
use Monitor\Api\Controller\TicketController;
|
use Monitor\Api\Controller\TicketController;
|
||||||
|
|
@ -26,6 +27,7 @@ use Monitor\Api\Repository\AgentRepository;
|
||||||
use Monitor\Api\Repository\EventRepository;
|
use Monitor\Api\Repository\EventRepository;
|
||||||
use Monitor\Api\Repository\KpiRepository;
|
use Monitor\Api\Repository\KpiRepository;
|
||||||
use Monitor\Api\Repository\LegacyStatusFileReader;
|
use Monitor\Api\Repository\LegacyStatusFileReader;
|
||||||
|
use Monitor\Api\Repository\RepoConfigRepository;
|
||||||
use Monitor\Api\Repository\SystemRepository;
|
use Monitor\Api\Repository\SystemRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
use Monitor\Api\Sync\SyncTrigger;
|
use Monitor\Api\Sync\SyncTrigger;
|
||||||
|
|
@ -50,6 +52,7 @@ final class Application
|
||||||
$events = new EventRepository($db);
|
$events = new EventRepository($db);
|
||||||
$systems = new SystemRepository($db);
|
$systems = new SystemRepository($db);
|
||||||
$kpis = new KpiRepository($db);
|
$kpis = new KpiRepository($db);
|
||||||
|
$repoConfigs = new RepoConfigRepository($db);
|
||||||
|
|
||||||
$this->agentAuth = new AgentAuthenticator($agents);
|
$this->agentAuth = new AgentAuthenticator($agents);
|
||||||
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
|
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
|
||||||
|
|
@ -60,6 +63,7 @@ final class Application
|
||||||
$eventController = new EventController($events);
|
$eventController = new EventController($events);
|
||||||
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
||||||
$kpiController = new KpiController($kpis);
|
$kpiController = new KpiController($kpis);
|
||||||
|
$repoConfigController = new RepoConfigController($repoConfigs);
|
||||||
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()));
|
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()));
|
||||||
$streamController = new StreamController(
|
$streamController = new StreamController(
|
||||||
new ChangeFeed($tickets, $events, $systems, $kpis, $agents),
|
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('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');
|
$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('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('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin');
|
||||||
$this->router->add(
|
$this->router->add(
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,6 @@ final class Config
|
||||||
return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/');
|
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
|
public function codebergToken(): ?string
|
||||||
{
|
{
|
||||||
return $this->get('CODEBERG_TOKEN');
|
return $this->get('CODEBERG_TOKEN');
|
||||||
|
|
|
||||||
53
api/src/Controller/RepoConfigController.php
Normal file
53
api/src/Controller/RepoConfigController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,14 +43,36 @@ final class Database
|
||||||
return $db;
|
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
|
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';
|
$migrationsDir = __DIR__ . '/migrations';
|
||||||
$files = glob($migrationsDir . '/*.sql') ?: [];
|
$files = glob($migrationsDir . '/*.sql') ?: [];
|
||||||
sort($files);
|
sort($files);
|
||||||
|
|
||||||
|
$markApplied = $this->pdo->prepare(
|
||||||
|
'INSERT INTO schema_migrations (filename, applied_at) VALUES (:filename, :applied_at)'
|
||||||
|
);
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
$name = basename($file);
|
||||||
|
if (in_array($name, $applied, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->pdo->exec((string) file_get_contents($file));
|
$this->pdo->exec((string) file_get_contents($file));
|
||||||
|
$markApplied->execute(['filename' => $name, 'applied_at' => $this->now()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
api/src/Db/migrations/002_multi_repo_tickets.sql
Normal file
11
api/src/Db/migrations/002_multi_repo_tickets.sql
Normal file
|
|
@ -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;
|
||||||
20
api/src/Db/migrations/003_repo_configs.sql
Normal file
20
api/src/Db/migrations/003_repo_configs.sql
Normal 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'));
|
||||||
148
api/src/Repository/RepoConfigRepository.php
Normal file
148
api/src/Repository/RepoConfigRepository.php
Normal 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,11 +33,12 @@ final class TicketRepository
|
||||||
|
|
||||||
$now = $this->db->now();
|
$now = $this->db->now();
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->db->pdo()->prepare(
|
||||||
'INSERT INTO tickets (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, :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([
|
$stmt->execute([
|
||||||
'external_ref' => $data['external_ref'] ?? null,
|
'external_ref' => $data['external_ref'] ?? null,
|
||||||
|
'repo' => $data['repo'] ?? null,
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'column_name' => $column,
|
'column_name' => $column,
|
||||||
|
|
@ -101,11 +102,17 @@ final class TicketRepository
|
||||||
return $row === false ? null : $row;
|
return $row === false ? null : $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed>|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<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findByExternalRef(string $externalRef, ?string $repo = null): ?array
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref');
|
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref AND repo IS :repo');
|
||||||
$stmt->execute(['ref' => $externalRef]);
|
$stmt->execute(['ref' => $externalRef, 'repo' => $repo]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
return $row === false ? null : $row;
|
return $row === false ? null : $row;
|
||||||
|
|
@ -143,7 +150,7 @@ final class TicketRepository
|
||||||
*/
|
*/
|
||||||
public function upsertFromCodeberg(array $data): array
|
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) {
|
if ($existing === null) {
|
||||||
return $this->create($data + ['source' => 'codeberg']);
|
return $this->create($data + ['source' => 'codeberg']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,17 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Monitor\Api\Sync;
|
namespace Monitor\Api\Sync;
|
||||||
|
|
||||||
|
use Monitor\Api\Repository\RepoConfigRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F4: "Backlog und Done werden regelmaessig von Codeberg ausgelesen." Offene Issues landen
|
* F4/F26: Liest offene und geschlossene Issues aus mehreren konfigurierten Codeberg-Projekten
|
||||||
* (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) je nach Label in Backlog,
|
* (RepoConfigRepository, Issue #27 vormals Config::codebergRepos()) und legt daraus Tickets
|
||||||
* Bereit, In Arbeit oder Testing, geschlossene in Done. Ticket-Typ und Farbe werden aus dem
|
* an bzw. aktualisiert sie. Offene Issues landen (falls noch nicht vorhanden bzw. noch nicht
|
||||||
* ersten Codeberg-Label uebernommen (F4: "Ticket-Typen ... koennen mit den Codeberg-Farben
|
* manuell bewegt) je nach Label in Backlog, Bereit, In Arbeit oder Testing, geschlossene in
|
||||||
* dargestellt werden").
|
* 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
|
final class CodebergSyncService
|
||||||
{
|
{
|
||||||
|
|
@ -28,19 +31,34 @@ final class CodebergSyncService
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CodebergClient $client,
|
private readonly CodebergClient $client,
|
||||||
private readonly TicketRepository $tickets,
|
private readonly TicketRepository $tickets,
|
||||||
private readonly string $owner,
|
private readonly RepoConfigRepository $repoConfigs,
|
||||||
private readonly string $repo,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
public function sync(): void
|
||||||
{
|
{
|
||||||
foreach ($this->client->listIssues($this->owner, $this->repo, 'open') as $issue) {
|
foreach ($this->repoConfigs->all() as $repoConfig) {
|
||||||
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, $this->columnForOpenIssue($issue)));
|
$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) {
|
foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) {
|
||||||
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done'));
|
$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<int, array{name: string, color: string}>} $issue
|
* @param array{number: int, title: string, body: ?string, labels: array<int, array{name: string, color: string}>} $issue
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
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;
|
$firstLabel = $issue['labels'][0] ?? null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'external_ref' => (string) $issue['number'],
|
'external_ref' => (string) $issue['number'],
|
||||||
|
'repo' => $repoSlug,
|
||||||
'title' => $issue['title'],
|
'title' => $issue['title'],
|
||||||
'description' => $issue['body'],
|
'description' => $issue['body'],
|
||||||
'column' => $column,
|
'column' => $column,
|
||||||
'type' => $firstLabel['name'] ?? null,
|
'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, '#');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,62 @@ final class ApplicationTest extends TestCase
|
||||||
self::assertSame(404, $response->status);
|
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
|
* 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
|
* Stream-Antwort - der eigentliche Polling-Loop wird hier bewusst NICHT ausgefuehrt
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ final class ConfigTest extends TestCase
|
||||||
$config = new Config([]);
|
$config = new Config([]);
|
||||||
|
|
||||||
self::assertSame('https://codeberg.org/api/v1', $config->codebergApiBase());
|
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->codebergToken());
|
||||||
self::assertNull($config->adminApiKey());
|
self::assertNull($config->adminApiKey());
|
||||||
self::assertSame(8, $config->deskCount());
|
self::assertSame(8, $config->deskCount());
|
||||||
|
|
@ -43,8 +41,6 @@ final class ConfigTest extends TestCase
|
||||||
{
|
{
|
||||||
$config = new Config([
|
$config = new Config([
|
||||||
'CODEBERG_API_BASE' => 'https://example.org/api/v1/',
|
'CODEBERG_API_BASE' => 'https://example.org/api/v1/',
|
||||||
'CODEBERG_OWNER' => 'someone',
|
|
||||||
'CODEBERG_REPO' => 'somerepo',
|
|
||||||
'CODEBERG_TOKEN' => 'tok',
|
'CODEBERG_TOKEN' => 'tok',
|
||||||
'MONITOR_ADMIN_API_KEY' => 'secret',
|
'MONITOR_ADMIN_API_KEY' => 'secret',
|
||||||
'MONITOR_DESK_COUNT' => '3',
|
'MONITOR_DESK_COUNT' => '3',
|
||||||
|
|
@ -53,8 +49,6 @@ final class ConfigTest extends TestCase
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
|
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('tok', $config->codebergToken());
|
||||||
self::assertSame('secret', $config->adminApiKey());
|
self::assertSame('secret', $config->adminApiKey());
|
||||||
self::assertSame(3, $config->deskCount());
|
self::assertSame(3, $config->deskCount());
|
||||||
|
|
|
||||||
82
api/tests/Unit/Controller/RepoConfigControllerTest.php
Normal file
82
api/tests/Unit/Controller/RepoConfigControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
api/tests/Unit/Repository/RepoConfigRepositoryTest.php
Normal file
99
api/tests/Unit/Repository/RepoConfigRepositoryTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Monitor\Api\Tests\Sync;
|
namespace Monitor\Api\Tests\Sync;
|
||||||
|
|
||||||
use Monitor\Api\Db\Database;
|
use Monitor\Api\Db\Database;
|
||||||
|
use Monitor\Api\Repository\RepoConfigRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
use Monitor\Api\Sync\CodebergClient;
|
use Monitor\Api\Sync\CodebergClient;
|
||||||
use Monitor\Api\Sync\CodebergSyncService;
|
use Monitor\Api\Sync\CodebergSyncService;
|
||||||
|
|
@ -12,23 +13,52 @@ use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class FakeCodebergClient implements CodebergClient
|
final class FakeCodebergClient implements CodebergClient
|
||||||
{
|
{
|
||||||
/** @param array<string, array<int, array{number:int,title:string,body:?string,labels:array}>> $issuesByState */
|
/** @param array<string, array<string, array<int, array{number:int,title:string,body:?string,labels:array}>>> $issuesByRepoAndState */
|
||||||
public function __construct(private readonly array $issuesByState)
|
public function __construct(private readonly array $issuesByRepoAndState)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listIssues(string $owner, string $repo, string $state): array
|
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
|
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<string, array<int, array{number:int,title:string,body:?string,labels: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<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());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
$client = new FakeCodebergClient([
|
$client = self::clientForMonitor([
|
||||||
'open' => [
|
'open' => [
|
||||||
['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]],
|
['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');
|
$backlog = $tickets->all('backlog');
|
||||||
$done = $tickets->all('done');
|
$done = $tickets->all('done');
|
||||||
|
|
@ -45,46 +75,64 @@ final class CodebergSyncServiceTest extends TestCase
|
||||||
self::assertCount(1, $backlog);
|
self::assertCount(1, $backlog);
|
||||||
self::assertSame('Offenes Issue', $backlog[0]['title']);
|
self::assertSame('Offenes Issue', $backlog[0]['title']);
|
||||||
self::assertSame('bug', $backlog[0]['type']);
|
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::assertSame('codeberg', $backlog[0]['source']);
|
||||||
|
|
||||||
self::assertCount(1, $done);
|
self::assertCount(1, $done);
|
||||||
self::assertSame('Geschlossenes Issue', $done[0]['title']);
|
self::assertSame('Geschlossenes Issue', $done[0]['title']);
|
||||||
self::assertNull($done[0]['type']);
|
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());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
$client = new FakeCodebergClient([
|
$client = new FakeCodebergClient([
|
||||||
'open' => [
|
'fds2610/monitor' => [
|
||||||
['number' => 1, 'title' => 'Mit Raute', 'body' => null, 'labels' => [['name' => 'bug', 'color' => '#00ff00']]],
|
'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
|
public function testSyncDoesNotResetManuallyMovedTicket(): void
|
||||||
{
|
{
|
||||||
$tickets = new TicketRepository(Database::inMemory());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
$client = new FakeCodebergClient([
|
$client = self::clientForMonitor([
|
||||||
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
|
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
|
||||||
'closed' => [],
|
'closed' => [],
|
||||||
]);
|
]);
|
||||||
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
|
$service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]));
|
||||||
|
|
||||||
$service->sync();
|
$service->sync();
|
||||||
$ticket = $tickets->findByExternalRef('1');
|
$ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||||
$tickets->update((int) $ticket['id'], ['column' => 'qa']);
|
$tickets->update((int) $ticket['id'], ['column' => 'qa']);
|
||||||
|
|
||||||
$service->sync();
|
$service->sync();
|
||||||
|
|
||||||
$stillQa = $tickets->findByExternalRef('1');
|
$stillQa = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||||
self::assertSame('qa', $stillQa['column_name']);
|
self::assertSame('qa', $stillQa['column_name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,65 +150,65 @@ final class CodebergSyncServiceTest extends TestCase
|
||||||
public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void
|
public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void
|
||||||
{
|
{
|
||||||
$tickets = new TicketRepository(Database::inMemory());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
$client = new FakeCodebergClient([
|
$client = self::clientForMonitor([
|
||||||
'open' => [
|
'open' => [
|
||||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]],
|
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]],
|
||||||
],
|
],
|
||||||
'closed' => [],
|
'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']);
|
self::assertSame($expectedColumn, $ticket['column_name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void
|
public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void
|
||||||
{
|
{
|
||||||
$tickets = new TicketRepository(Database::inMemory());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
$client = new FakeCodebergClient([
|
$client = self::clientForMonitor([
|
||||||
'open' => [
|
'open' => [
|
||||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]],
|
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]],
|
||||||
],
|
],
|
||||||
'closed' => [],
|
'closed' => [],
|
||||||
]);
|
]);
|
||||||
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
|
$service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]));
|
||||||
|
|
||||||
$service->sync();
|
$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' => [
|
'open' => [
|
||||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]],
|
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]],
|
||||||
],
|
],
|
||||||
'closed' => [],
|
'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
|
public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void
|
||||||
{
|
{
|
||||||
$tickets = new TicketRepository(Database::inMemory());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
$client = new FakeCodebergClient([
|
$client = self::clientForMonitor([
|
||||||
'open' => [
|
'open' => [
|
||||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]],
|
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]],
|
||||||
],
|
],
|
||||||
'closed' => [],
|
'closed' => [],
|
||||||
]);
|
]);
|
||||||
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
|
$service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]));
|
||||||
$service->sync();
|
$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' => [],
|
'open' => [],
|
||||||
'closed' => [
|
'closed' => [
|
||||||
['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]],
|
['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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ services:
|
||||||
MONITOR_SSE_POLL_INTERVAL: 2
|
MONITOR_SSE_POLL_INTERVAL: 2
|
||||||
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
||||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
|
||||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
|
||||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
||||||
# Secret des Codeberg-Webhooks. Ohne diesen Wert weist POST /api/v1/webhooks/codeberg
|
# 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
|
# 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_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600}
|
||||||
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
||||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
|
||||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
|
||||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
||||||
command: php bin/sync-codeberg.php --watch
|
command: php bin/sync-codeberg.php --watch
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,3 +569,106 @@ header.office-header h1 {
|
||||||
background: #10131a;
|
background: #10131a;
|
||||||
border-radius: 6px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,75 @@ export async function fetchAgents() {
|
||||||
export function streamUrl() {
|
export function streamUrl() {
|
||||||
return `${apiBase()}/stream`;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@
|
||||||
|
|
||||||
let dialogEl = null;
|
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() {
|
function ensureDialog() {
|
||||||
if (dialogEl) {
|
if (dialogEl) {
|
||||||
return dialogEl;
|
return dialogEl;
|
||||||
|
|
@ -30,8 +37,12 @@ function ensureDialog() {
|
||||||
*
|
*
|
||||||
* @param {{title: string}} content
|
* @param {{title: string}} content
|
||||||
* @param {(body: HTMLElement, content: object) => void} renderBody
|
* @param {(body: HTMLElement, content: object) => void} renderBody
|
||||||
|
* @returns {number} Token fuer isCurrentModal() - siehe openToken oben.
|
||||||
*/
|
*/
|
||||||
export function openModal(content, renderBody) {
|
export function openModal(content, renderBody) {
|
||||||
|
openToken += 1;
|
||||||
|
const token = openToken;
|
||||||
|
|
||||||
const dialog = ensureDialog();
|
const dialog = ensureDialog();
|
||||||
dialog.querySelector(".office-modal-title").textContent = content.title;
|
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).
|
// Fallback fuer Umgebungen ohne <dialog>-Unterstuetzung (z.B. sehr alte Browser).
|
||||||
dialog.setAttribute("open", "");
|
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() {
|
export function closeModal() {
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,21 @@ import {
|
||||||
DEMO_EVENTS,
|
DEMO_EVENTS,
|
||||||
DEMO_KPI_PLACEHOLDER,
|
DEMO_KPI_PLACEHOLDER,
|
||||||
} from "./demo-data.js";
|
} 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 { 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";
|
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 {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
|
||||||
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
|
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
|
||||||
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
|
* 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" });
|
const g = el("g", { class: "desks" });
|
||||||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||||
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
||||||
poDeskGroup.classList.add("po-desk");
|
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);
|
g.appendChild(poDeskGroup);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
@ -947,6 +967,291 @@ function emptyMessage(message) {
|
||||||
return p;
|
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
|
// Datenbeschaffung + Orchestrierung
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
@ -1042,7 +1347,7 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
|
|
||||||
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
||||||
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
|
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");
|
const existingDesks = svg.querySelector(".desks");
|
||||||
if (existingDesks) {
|
if (existingDesks) {
|
||||||
existingDesks.replaceWith(desksGroup);
|
existingDesks.replaceWith(desksGroup);
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,9 @@ export function groupTicketsByColumn(tickets = []) {
|
||||||
/**
|
/**
|
||||||
* Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter
|
* 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
|
* 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 {string} columnKey
|
||||||
* @param {Array<object>} tickets
|
* @param {Array<object>} tickets
|
||||||
|
|
@ -167,7 +169,7 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
|
||||||
color: ticket.color || null,
|
color: ticket.color || null,
|
||||||
link:
|
link:
|
||||||
ticket.source === "codeberg" && ticket.external_ref
|
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,
|
: null,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,15 @@ test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () =>
|
||||||
assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7");
|
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
|
// Phase 4 - Monitor-Wand live
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue