From 3aaf6aea54f3008fcf235c5cdde7766a3fe55b6b Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 30 Jul 2026 17:34:49 +0200 Subject: [PATCH] Tickets aus mehreren Codeberg-Projekten anzeigen (monitor/Steuerkram/nc-gantt) Der Codeberg-Sync liest nun Issues aus mehreren konfigurierbaren Projekten (Config::codebergRepos(), Default: fds2610/monitor gelb, fds2610/Steuerkram gruen, fds2610/nc-gantt blau) statt nur aus einem einzigen Repo. Damit external_ref (Issue-Nummer) projektuebergreifend nicht kollidiert, ist es jetzt zusammen mit dem neuen Ticket-Feld "repo" eindeutig; Migration 002_multi_repo_tickets.sql ergaenzt die Spalte und den Index. Da Migrationen dadurch nicht mehr rein additiv sind, trackt Database::migrate() jetzt angewandte Dateien in schema_migrations, damit ALTER TABLE nicht bei jedem Prozessstart erneut laeuft. Das Frontend baut Codeberg-Links jetzt aus dem repo-Feld jedes Tickets statt aus einer global fixen owner/repo-Angabe. Schliesst #26 --- .env.example | 9 +- API.md | 35 ++++--- README.md | 10 +- api/bin/sync-codeberg.php | 3 +- api/src/Config.php | 47 ++++++++-- api/src/Db/Database.php | 22 +++++ .../Db/migrations/002_multi_repo_tickets.sql | 11 +++ api/src/Repository/TicketRepository.php | 21 +++-- api/src/Sync/CodebergSyncService.php | 52 ++++++----- api/tests/Unit/ConfigTest.php | 27 ++++-- .../Unit/Sync/CodebergSyncServiceTest.php | 93 ++++++++++++------- docker-compose.yml | 9 +- html/js/office-scene.js | 6 +- tests/frontend/office-scene.test.js | 9 ++ 14 files changed, 250 insertions(+), 104 deletions(-) create mode 100644 api/src/Db/migrations/002_multi_repo_tickets.sql diff --git a/.env.example b/.env.example index 723ae1c..b315532 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,12 @@ MONITOR_ADMIN_API_KEY=change-me # die relative URL /api/v1 verwendet). MONITOR_API_BASE=http://localhost:8091/api/v1 -# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden -CODEBERG_OWNER=fds2610 -CODEBERG_REPO=monitor +# Codeberg-Projekte, aus denen Backlog/Done per Sync-Skript befuellt werden. Leer lassen fuer +# die Default-Projekte (fds2610/monitor gelb, fds2610/Steuerkram gruen, fds2610/nc-gantt +# blau, siehe Config::codebergRepos()). Eigenes Format bei Bedarf: +# "owner/repo:farbe,owner/repo:farbe,..." (Farbe mit oder ohne fuehrendes '#'), z.B. +# CODEBERG_REPOS=fds2610/monitor:fbca04,fds2610/Steuerkram:28a745,fds2610/nc-gantt:0366d6 +CODEBERG_REPOS= # Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind - bei kleinem # MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert CODEBERG_TOKEN= diff --git a/API.md b/API.md index b89663a..d236c85 100644 --- a/API.md +++ b/API.md @@ -134,11 +134,16 @@ Response 200: aktualisiertes Agent-Objekt. Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`. - `backlog`, `bereit`, `in_arbeit`, `testing` und `done` werden regelmaessig aus - Codeberg-Issues befuellt (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und - `fertig` sind fuer zukuenftige Labels reserviert und ergeben sich ausschliesslich aus - Agenten-Meldungen (F4). -- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten - Issue-Label uebernommen (F4). + Codeberg-Issues aus mehreren konfigurierten Projekten befuellt (siehe + [Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und `fertig` sind fuer zukuenftige + Labels reserviert und ergeben sich ausschliesslich aus Agenten-Meldungen (F4). +- Ticket-Typ (statt Prioritaet) wird bei Codeberg-Tickets aus dem ersten Issue-Label + uebernommen, die Farbe dagegen fest aus dem Quell-Projekt (F4/F26) - so bleiben Tickets aus + unterschiedlichen Codeberg-Projekten auf einen Blick unterscheidbar, unabhaengig davon, + welche Labels dort jeweils existieren. +- `repo` (Format `owner/name`) identifiziert bei Codeberg-Tickets das Quell-Projekt und macht + `external_ref` (die Issue-Nummer) nur je Projekt eindeutig - Issue #5 in `fds2610/monitor` + und Issue #5 in `fds2610/Steuerkram` sind unterschiedliche Tickets. - Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik; die API liefert Tickets dafuer sortiert nach Spalte und Alter. @@ -146,7 +151,7 @@ Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done Query-Parameter: `column` (optional, filtert auf eine Spalte). -Response: `{ "tickets": [ { "id", "external_ref", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }` +Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }` ### `POST /api/v1/tickets` — Ticket anlegen @@ -265,8 +270,10 @@ schliessen. ## Codeberg-Sync (Backlog/Done) -`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus dem konfigurierten -Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external_ref` +`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten +Codeberg-Projekten (`CODEBERG_REPOS`, siehe `.env.example` - ohne Override die drei +Default-Projekte `fds2610/monitor` (gelb), `fds2610/Steuerkram` (gruen) und +`fds2610/nc-gantt` (blau)) und gleicht sie je Projekt ueber `repo` + `external_ref` (= Issue-Nummer) mit den Tickets ab: - Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`, @@ -275,7 +282,7 @@ Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external - Nur falls das Ticket noch in einer der Sync-Spalten (`backlog`, `bereit`, `in_arbeit`, `testing`, `done`) ist - auf `qa`/`fertig` manuell verschobene Tickets werden nicht zurueckgesetzt. -- Ticket-Typ/-Farbe kommen vom ersten Issue-Label (Name/Farbe). +- Ticket-Typ kommt vom ersten Issue-Label, die Farbe fest vom Projekt (siehe oben). Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe [docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit @@ -312,8 +319,14 @@ erfolgreichen Lauf gilt sofort wieder das regulaere Intervall. Im Log sieht das Damit Issue-Aenderungen nicht erst beim naechsten periodischen Lauf auf dem Board landen, kann Codeberg jede Aenderung aktiv melden. Der Endpunkt wertet den Payload **nicht** aus, er fordert nur einen regulaeren Sync an; der `sync`-Service greift die Anforderung im -Sekundentakt auf und laedt den aktuellen Stand. Vom Aendern des Issues bis zum Board -vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend). +Sekundentakt auf und laedt den aktuellen Stand **aller** konfigurierten Projekte +(`CODEBERG_REPOS`), nicht nur das des ausloesenden Repos. Vom Aendern des Issues bis zum +Board vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend). + +Bei mehreren Projekten muss der Webhook in **jedem** Repo eingerichtet werden, dessen +Aenderungen sofort statt erst beim naechsten periodischen Lauf ankommen sollen - ein +fehlender Webhook auf einem der Projekte faengt der periodische Sync (`MONITOR_SYNC_INTERVAL`) +weiterhin ab. Authentifizierung ist **kein** API-Key, sondern die HMAC-Signatur, die Codeberg selbst mitschickt: `X-Forgejo-Signature` (bzw. `X-Gitea-Signature`) enthaelt den HMAC-SHA256 des diff --git a/README.md b/README.md index 6f65613..e8ffdac 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und 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`). - Codeberg-Sync als Dauerläufer (Compose-Service `sync`): füllt Backlog und Done automatisch - aus den Issues des konfigurierten Codeberg-Repos — periodisch alle `MONITOR_SYNC_INTERVAL` - Sekunden und, wenn ein Webhook eingerichtet ist, innerhalb von Sekunden nach jeder - Issue-Änderung (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per - `CODEBERG_WEBHOOK_SECRET`). Siehe [API.md](API.md) Abschnitt „Codeberg-Sync" — - dort steht auch die Einrichtung im Codeberg-Repo. + aus den Issues mehrerer konfigurierter Codeberg-Projekte (`CODEBERG_REPOS`, je Projekt mit + eigener Ticket-Farbe) — periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden und, wenn ein + Webhook eingerichtet ist, innerhalb von Sekunden nach jeder Issue-Änderung + (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per `CODEBERG_WEBHOOK_SECRET`). Siehe + [API.md](API.md) Abschnitt „Codeberg-Sync" — dort steht auch die Einrichtung im Codeberg-Repo. - Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php index 0c72cfa..6505f55 100755 --- a/api/bin/sync-codeberg.php +++ b/api/bin/sync-codeberg.php @@ -19,8 +19,7 @@ $db = Database::forFile($config->dbPath()); $service = new CodebergSyncService( new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()), new TicketRepository($db), - $config->codebergOwner(), - $config->codebergRepo(), + $config->codebergRepos(), ); // Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README). diff --git a/api/src/Config.php b/api/src/Config.php index d332f6d..b07a8c0 100644 --- a/api/src/Config.php +++ b/api/src/Config.php @@ -54,14 +54,49 @@ final class Config return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/'); } - public function codebergOwner(): string - { - return (string) $this->get('CODEBERG_OWNER', 'fds2610'); - } + /** + * Default-Projekte fuer den Codeberg-Sync (Issue #26: "Tickets auch von anderen + * Codeberg-Projekten anzeigen"). Jedes Projekt bekommt eine feste Farbe, an der sich + * Tickets auf dem Board unabhaengig vom Issue-Label ihrem Ursprungsprojekt zuordnen + * lassen. + */ + private const DEFAULT_REPOS = [ + ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'], + ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], + ['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'], + ]; - public function codebergRepo(): string + /** + * Codeberg-Projekte, aus denen der Sync Tickets liest. Override per + * CODEBERG_REPOS="owner/repo:farbe,owner/repo:farbe,..." (Farbe mit oder ohne + * fuehrendes "#"); ohne gesetzte/gueltige Variable gelten die drei Default-Projekte. + * + * @return array + */ + public function codebergRepos(): array { - return (string) $this->get('CODEBERG_REPO', 'monitor'); + $raw = $this->get('CODEBERG_REPOS'); + if ($raw === null) { + return self::DEFAULT_REPOS; + } + + $repos = []; + foreach (explode(',', $raw) as $entry) { + $entry = trim($entry); + if ($entry === '') { + continue; + } + + [$slug, $color] = array_pad(explode(':', $entry, 2), 2, null); + [$owner, $repo] = array_pad(explode('/', (string) $slug, 2), 2, null); + if ($owner === null || $owner === '' || $repo === null || $repo === '' || $color === null || $color === '') { + continue; + } + + $repos[] = ['owner' => $owner, 'repo' => $repo, 'color' => '#' . ltrim($color, '#')]; + } + + return $repos === [] ? self::DEFAULT_REPOS : $repos; } public function codebergToken(): ?string diff --git a/api/src/Db/Database.php b/api/src/Db/Database.php index 238f3a0..c353a20 100644 --- a/api/src/Db/Database.php +++ b/api/src/Db/Database.php @@ -43,14 +43,36 @@ final class Database return $db; } + /** + * Fuehrt jede Migration in migrations/ genau einmal aus (Tracking in schema_migrations). + * Noetig, seit Migrationen nicht mehr rein additiv sind (z.B. ALTER TABLE ... ADD COLUMN + * in 002_multi_repo_tickets.sql) - ein erneutes exec() bei jedem Request/Test-Setup + * (forFile()/inMemory() rufen migrate() bei jeder Instanziierung auf) wuerde sonst ab + * dem zweiten Aufruf mit "duplicate column name" abbrechen. + */ public function migrate(): void { + $this->pdo->exec( + 'CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL)' + ); + $applied = $this->pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN); + $migrationsDir = __DIR__ . '/migrations'; $files = glob($migrationsDir . '/*.sql') ?: []; sort($files); + $markApplied = $this->pdo->prepare( + 'INSERT INTO schema_migrations (filename, applied_at) VALUES (:filename, :applied_at)' + ); + foreach ($files as $file) { + $name = basename($file); + if (in_array($name, $applied, true)) { + continue; + } + $this->pdo->exec((string) file_get_contents($file)); + $markApplied->execute(['filename' => $name, 'applied_at' => $this->now()]); } } diff --git a/api/src/Db/migrations/002_multi_repo_tickets.sql b/api/src/Db/migrations/002_multi_repo_tickets.sql new file mode 100644 index 0000000..f4a7cea --- /dev/null +++ b/api/src/Db/migrations/002_multi_repo_tickets.sql @@ -0,0 +1,11 @@ +-- F26: Tickets aus mehreren Codeberg-Projekten unterscheiden. "repo" speichert das +-- Quell-Repo als "owner/name" und macht external_ref nur noch je Repo eindeutig - Issue #5 +-- in fds2610/monitor und Issue #5 in fds2610/Steuerkram sind zwei unterschiedliche Tickets. +ALTER TABLE tickets ADD COLUMN repo TEXT; + +-- Vor dieser Migration gab es nur den einen synchronisierten Repo. +UPDATE tickets SET repo = 'fds2610/monitor' WHERE source = 'codeberg' AND repo IS NULL; + +DROP INDEX IF EXISTS idx_tickets_external_ref; +CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_external_ref ON tickets (repo, external_ref) + WHERE external_ref IS NOT NULL; diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index 34f4f23..011ce94 100644 --- a/api/src/Repository/TicketRepository.php +++ b/api/src/Repository/TicketRepository.php @@ -33,11 +33,12 @@ final class TicketRepository $now = $this->db->now(); $stmt = $this->db->pdo()->prepare( - 'INSERT INTO tickets (external_ref, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at) - VALUES (:external_ref, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)' + 'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at) + VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)' ); $stmt->execute([ 'external_ref' => $data['external_ref'] ?? null, + 'repo' => $data['repo'] ?? null, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'column_name' => $column, @@ -101,11 +102,17 @@ final class TicketRepository return $row === false ? null : $row; } - /** @return array|null */ - public function findByExternalRef(string $externalRef): ?array + /** + * external_ref (Issue-Nummer) ist nur je Repo eindeutig (F26: mehrere Codeberg-Projekte), + * daher zusaetzlich ueber "repo" ("owner/name") gescoped. $repo bleibt optional/null fuer + * Tickets ohne Codeberg-Projektbezug. + * + * @return array|null + */ + public function findByExternalRef(string $externalRef, ?string $repo = null): ?array { - $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref'); - $stmt->execute(['ref' => $externalRef]); + $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref AND repo IS :repo'); + $stmt->execute(['ref' => $externalRef, 'repo' => $repo]); $row = $stmt->fetch(); return $row === false ? null : $row; @@ -143,7 +150,7 @@ final class TicketRepository */ public function upsertFromCodeberg(array $data): array { - $existing = $this->findByExternalRef((string) $data['external_ref']); + $existing = $this->findByExternalRef((string) $data['external_ref'], $data['repo'] ?? null); if ($existing === null) { return $this->create($data + ['source' => 'codeberg']); } diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 4f04db4..843d7a3 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -7,11 +7,13 @@ namespace Monitor\Api\Sync; use Monitor\Api\Repository\TicketRepository; /** - * F4: "Backlog und Done werden regelmaessig von Codeberg ausgelesen." Offene Issues landen - * (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) je nach Label in Backlog, - * Bereit, In Arbeit oder Testing, geschlossene in Done. Ticket-Typ und Farbe werden aus dem - * ersten Codeberg-Label uebernommen (F4: "Ticket-Typen ... koennen mit den Codeberg-Farben - * dargestellt werden"). + * F4/F26: Liest offene und geschlossene Issues aus mehreren konfigurierten Codeberg-Projekten + * (Config::codebergRepos()) und legt daraus Tickets an bzw. aktualisiert sie. Offene Issues + * landen (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) je nach Label in + * Backlog, Bereit, In Arbeit oder Testing, geschlossene in Done. Der Ticket-Typ (Badge-Text) + * kommt vom ersten Codeberg-Label, die Ticket-Farbe dagegen fest vom Quell-Projekt (Issue + * #26: "unterschiedliche Codeberg-Projekte sollen an fest zugeordneten Farben erkennbar + * sein"), nicht mehr von der Label-Farbe. */ final class CodebergSyncService { @@ -25,22 +27,33 @@ final class CodebergSyncService 'automate' => 'bereit', ]; + /** @param array $repos */ public function __construct( private readonly CodebergClient $client, private readonly TicketRepository $tickets, - private readonly string $owner, - private readonly string $repo, + private readonly array $repos, ) { } public function sync(): void { - foreach ($this->client->listIssues($this->owner, $this->repo, 'open') as $issue) { - $this->tickets->upsertFromCodeberg($this->mapIssue($issue, $this->columnForOpenIssue($issue))); + foreach ($this->repos as $repoConfig) { + $this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']); + } + } + + private function syncRepo(string $owner, string $repo, string $color): void + { + $slug = $owner . '/' . $repo; + + foreach ($this->client->listIssues($owner, $repo, 'open') as $issue) { + $this->tickets->upsertFromCodeberg( + $this->mapIssue($issue, $this->columnForOpenIssue($issue), $slug, $color) + ); } - foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) { - $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done')); + foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) { + $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done', $slug, $color)); } } @@ -67,29 +80,18 @@ final class CodebergSyncService * @param array{number: int, title: string, body: ?string, labels: array} $issue * @return array */ - private function mapIssue(array $issue, string $column): array + private function mapIssue(array $issue, string $column, string $repoSlug, string $color): array { $firstLabel = $issue['labels'][0] ?? null; return [ 'external_ref' => (string) $issue['number'], + 'repo' => $repoSlug, 'title' => $issue['title'], 'description' => $issue['body'], 'column' => $column, 'type' => $firstLabel['name'] ?? null, - 'color' => self::normalizeColor($firstLabel['color'] ?? null), + 'color' => $color, ]; } - - /** - * Codeberg liefert Label-Farben mal mit, mal ohne fuehrendes "#". Die Normalisierung - * gehoert hierher und nicht in den Client, damit jede CodebergClient-Implementierung - * dieselbe CSS-taugliche Farbe im Ticket ablegt. - */ - private static function normalizeColor(?string $color): ?string - { - $color = trim((string) $color); - - return $color === '' ? null : '#' . ltrim($color, '#'); - } } diff --git a/api/tests/Unit/ConfigTest.php b/api/tests/Unit/ConfigTest.php index ee995ac..8b57b24 100644 --- a/api/tests/Unit/ConfigTest.php +++ b/api/tests/Unit/ConfigTest.php @@ -14,8 +14,11 @@ final class ConfigTest extends TestCase $config = new Config([]); self::assertSame('https://codeberg.org/api/v1', $config->codebergApiBase()); - self::assertSame('fds2610', $config->codebergOwner()); - self::assertSame('monitor', $config->codebergRepo()); + self::assertSame([ + ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'], + ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], + ['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'], + ], $config->codebergRepos()); self::assertNull($config->codebergToken()); self::assertNull($config->adminApiKey()); self::assertSame(8, $config->deskCount()); @@ -43,8 +46,7 @@ final class ConfigTest extends TestCase { $config = new Config([ 'CODEBERG_API_BASE' => 'https://example.org/api/v1/', - 'CODEBERG_OWNER' => 'someone', - 'CODEBERG_REPO' => 'somerepo', + 'CODEBERG_REPOS' => 'someone/somerepo:ff00aa, other/thing:00ff00 ,malformed', 'CODEBERG_TOKEN' => 'tok', 'MONITOR_ADMIN_API_KEY' => 'secret', 'MONITOR_DESK_COUNT' => '3', @@ -53,12 +55,25 @@ final class ConfigTest extends TestCase ]); self::assertSame('https://example.org/api/v1', $config->codebergApiBase()); - self::assertSame('someone', $config->codebergOwner()); - self::assertSame('somerepo', $config->codebergRepo()); + self::assertSame([ + ['owner' => 'someone', 'repo' => 'somerepo', 'color' => '#ff00aa'], + ['owner' => 'other', 'repo' => 'thing', 'color' => '#00ff00'], + ], $config->codebergRepos()); self::assertSame('tok', $config->codebergToken()); self::assertSame('secret', $config->adminApiKey()); self::assertSame(3, $config->deskCount()); self::assertSame(5, $config->sseMaxIterations()); self::assertSame(1, $config->ssePollIntervalSeconds()); } + + public function testCodebergReposFallsBackToDefaultsWhenOverrideIsUnusable(): void + { + $config = new Config(['CODEBERG_REPOS' => 'nur-ein-slug-ohne-doppelpunkt']); + + self::assertSame([ + ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'], + ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], + ['owner' => 'fds2610', 'repo' => 'nc-gantt', 'color' => '#0366d6'], + ], $config->codebergRepos()); + } } diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index 845b067..aa5fe26 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -12,23 +12,32 @@ use PHPUnit\Framework\TestCase; final class FakeCodebergClient implements CodebergClient { - /** @param array> $issuesByState */ - public function __construct(private readonly array $issuesByState) + /** @param array>> $issuesByRepoAndState */ + public function __construct(private readonly array $issuesByRepoAndState) { } public function listIssues(string $owner, string $repo, string $state): array { - return $this->issuesByState[$state] ?? []; + return $this->issuesByRepoAndState[$owner . '/' . $repo][$state] ?? []; } } final class CodebergSyncServiceTest extends TestCase { - public function testSyncCreatesBacklogAndDoneTickets(): void + private const MONITOR_REPO = ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04']; + private const MONITOR_SLUG = 'fds2610/monitor'; + + /** @param array> $issuesByState */ + private static function clientForMonitor(array $issuesByState): FakeCodebergClient + { + return new FakeCodebergClient([self::MONITOR_SLUG => $issuesByState]); + } + + public function testSyncCreatesBacklogAndDoneTicketsColoredByProject(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]], ], @@ -37,7 +46,7 @@ final class CodebergSyncServiceTest extends TestCase ], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync(); $backlog = $tickets->all('backlog'); $done = $tickets->all('done'); @@ -45,46 +54,64 @@ final class CodebergSyncServiceTest extends TestCase self::assertCount(1, $backlog); self::assertSame('Offenes Issue', $backlog[0]['title']); self::assertSame('bug', $backlog[0]['type']); - self::assertSame('#ff0000', $backlog[0]['color']); + self::assertSame('#fbca04', $backlog[0]['color']); + self::assertSame(self::MONITOR_SLUG, $backlog[0]['repo']); self::assertSame('codeberg', $backlog[0]['source']); self::assertCount(1, $done); self::assertSame('Geschlossenes Issue', $done[0]['title']); self::assertNull($done[0]['type']); - self::assertNull($done[0]['color']); + self::assertSame('#fbca04', $done[0]['color']); } - public function testSyncNormalizesLabelColorRegardlessOfLeadingHash(): void + public function testSyncKeepsIssuesFromDifferentProjectsSeparateAndColorsThemPerProject(): void { $tickets = new TicketRepository(Database::inMemory()); $client = new FakeCodebergClient([ - 'open' => [ - ['number' => 1, 'title' => 'Mit Raute', 'body' => null, 'labels' => [['name' => 'bug', 'color' => '#00ff00']]], + 'fds2610/monitor' => [ + 'open' => [['number' => 1, 'title' => 'Monitor-Issue 1', 'body' => null, 'labels' => []]], + 'closed' => [], + ], + 'fds2610/Steuerkram' => [ + 'open' => [['number' => 1, 'title' => 'Steuerkram-Issue 1', 'body' => null, 'labels' => []]], + 'closed' => [], ], - 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, [ + self::MONITOR_REPO, + ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], + ]))->sync(); - self::assertSame('#00ff00', $tickets->all('backlog')[0]['color']); + $backlog = $tickets->all('backlog'); + self::assertCount(2, $backlog, 'gleiche Issue-Nummer aus verschiedenen Projekten darf nicht zu einem Ticket verschmelzen'); + + $monitorTicket = $tickets->findByExternalRef('1', 'fds2610/monitor'); + $steuerkramTicket = $tickets->findByExternalRef('1', 'fds2610/Steuerkram'); + + self::assertSame('Monitor-Issue 1', $monitorTicket['title']); + self::assertSame('#fbca04', $monitorTicket['color']); + + self::assertSame('Steuerkram-Issue 1', $steuerkramTicket['title']); + self::assertSame('#28a745', $steuerkramTicket['color']); } public function testSyncDoesNotResetManuallyMovedTicket(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]); $service->sync(); - $ticket = $tickets->findByExternalRef('1'); + $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); $tickets->update((int) $ticket['id'], ['column' => 'qa']); $service->sync(); - $stillQa = $tickets->findByExternalRef('1'); + $stillQa = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertSame('qa', $stillQa['column_name']); } @@ -102,65 +129,65 @@ final class CodebergSyncServiceTest extends TestCase public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]], ], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync(); - $ticket = $tickets->findByExternalRef('1'); + $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertSame($expectedColumn, $ticket['column_name']); } public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]], ], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]); $service->sync(); - self::assertSame('bereit', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('bereit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]], ], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync(); - self::assertSame('in_arbeit', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void { $tickets = new TicketRepository(Database::inMemory()); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service = new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]); $service->sync(); - self::assertSame('testing', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('testing', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); - $client = new FakeCodebergClient([ + $client = self::clientForMonitor([ 'open' => [], 'closed' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], ]); - (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + (new CodebergSyncService($client, $tickets, [self::MONITOR_REPO]))->sync(); - self::assertSame('done', $tickets->findByExternalRef('1')['column_name']); + self::assertSame('done', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } } diff --git a/docker-compose.yml b/docker-compose.yml index e91d02c..8008b9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,8 +22,10 @@ services: MONITOR_SSE_POLL_INTERVAL: 2 MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested CODEBERG_API_BASE: https://codeberg.org/api/v1 - CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} - CODEBERG_REPO: ${CODEBERG_REPO:-monitor} + # Leer = Default-Projekte aus Config::codebergRepos() (fds2610/monitor, .../Steuerkram, + # .../nc-gantt, siehe API.md "Codeberg-Sync"). Override-Format: + # "owner/repo:farbe,owner/repo:farbe,...". + CODEBERG_REPOS: ${CODEBERG_REPOS:-} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} # Secret des Codeberg-Webhooks. Ohne diesen Wert weist POST /api/v1/webhooks/codeberg # jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die @@ -57,8 +59,7 @@ services: MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600} MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested CODEBERG_API_BASE: https://codeberg.org/api/v1 - CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} - CODEBERG_REPO: ${CODEBERG_REPO:-monitor} + CODEBERG_REPOS: ${CODEBERG_REPOS:-} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} command: php bin/sync-codeberg.php --watch diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 989134c..ca75d02 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -147,7 +147,9 @@ export function groupTicketsByColumn(tickets = []) { /** * Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter * Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per - * Codeberg-Sync angelegt wurde (F4). + * Codeberg-Sync angelegt wurde (F4). Seit F26 (Tickets aus mehreren Codeberg-Projekten) + * traegt jedes Ticket sein Quell-Repo ("owner/name") selbst in ticket.repo; opts dient nur + * noch als Fallback fuer aeltere Tickets ohne dieses Feld. * * @param {string} columnKey * @param {Array} tickets @@ -167,7 +169,7 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) { color: ticket.color || null, link: ticket.source === "codeberg" && ticket.external_ref - ? `https://codeberg.org/${codebergOwner}/${codebergRepo}/issues/${ticket.external_ref}` + ? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/issues/${ticket.external_ref}` : null, })), }; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 05715c0..ca77381 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -174,6 +174,15 @@ test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () => assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7"); }); +test("buildTicketModalContent baut den Link aus dem repo-Feld des Tickets, nicht aus opts (mehrere Codeberg-Projekte, F26)", () => { + const content = buildTicketModalContent("backlog", [ + { id: 1, title: "A", source: "codeberg", external_ref: "3", repo: "fds2610/Steuerkram", color: "#28a745" }, + { id: 2, title: "B", source: "codeberg", external_ref: "9", repo: "fds2610/nc-gantt", color: "#0366d6" }, + ]); + assert.equal(content.items[0].link, "https://codeberg.org/fds2610/Steuerkram/issues/3"); + assert.equal(content.items[1].link, "https://codeberg.org/fds2610/nc-gantt/issues/9"); +}); + // --------------------------------------------------------------------------------------- // Phase 4 - Monitor-Wand live // ---------------------------------------------------------------------------------------