From 29a13e89ebae79b87cf0632fa3a5d9aabcea2fd8 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 29 Jul 2026 16:48:23 +0200 Subject: [PATCH] Codeberg-Sync ordnet offene Issues per Label der richtigen Spalte zu Bisher landeten alle offenen Issues unabhaengig von ihren Labels in Backlog. Jetzt bestimmt das Label die Spalte: wip -> in_arbeit, testing -> testing, automate -> bereit, sonst backlog. Geschlossene Issues bleiben in Done. Die "manuell verschoben"-Sperre im TicketRepository schuetzt jetzt nur noch qa/fertig (zukuenftige Labels) statt aller Nicht-Backlog/Done-Spalten, damit der Sync Tickets weiterhin zwischen den automatisierten Spalten verschieben kann, wenn sich Labels aendern. Schliesst #14 --- API.md | 14 ++-- api/src/Repository/TicketRepository.php | 14 +++- api/src/Sync/CodebergSyncService.php | 38 +++++++++- .../Unit/Repository/TicketRepositoryTest.php | 19 ++++- .../Unit/Sync/CodebergSyncServiceTest.php | 76 +++++++++++++++++++ 5 files changed, 148 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 43ce06c..5835084 100644 --- a/API.md +++ b/API.md @@ -104,9 +104,10 @@ Response 200: aktualisiertes Agent-Objekt. Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`. -- `backlog` und `done` werden zusaetzlich regelmaessig aus Codeberg-Issues befuellt - (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)), alle anderen Bewegungen ergeben - sich ausschliesslich aus Agenten-Meldungen (F4). +- `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). - Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik; @@ -239,9 +240,12 @@ schliessen. Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external_ref` (= Issue-Nummer) mit den Tickets ab: -- Offene Issues → Spalte `backlog` (nur falls das Ticket noch in `backlog`/`done` ist - - manuell auf dem Board bewegte Tickets werden nicht zurueckgesetzt). +- Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`, + sonst `automate` → `bereit`, sonst `backlog`. - Geschlossene Issues → Spalte `done`. +- 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). Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index f557497..34f4f23 100644 --- a/api/src/Repository/TicketRepository.php +++ b/api/src/Repository/TicketRepository.php @@ -13,6 +13,14 @@ final class TicketRepository /** @var string[] */ public const COLUMNS = ['backlog', 'bereit', 'in_arbeit', 'qa', 'testing', 'fertig', 'done']; + /** + * Spalten, die der Codeberg-Sync anhand von Issue-Status/Labels pflegt (F14). "qa" und + * "fertig" sind fuer zukuenftige Labels reserviert und bleiben rein manuell. + * + * @var string[] + */ + private const SYNC_MANAGED_COLUMNS = ['backlog', 'bereit', 'in_arbeit', 'testing', 'done']; + public function __construct(private readonly Database $db) { } @@ -140,9 +148,9 @@ final class TicketRepository return $this->create($data + ['source' => 'codeberg']); } - // Manuell auf dem Board bewegte Tickets (z. B. in_arbeit/qa/testing) werden vom - // periodischen Codeberg-Sync nicht zurueckgesetzt - nur Backlog/Done werden gepflegt. - if (!in_array($existing['column_name'], ['backlog', 'done'], true)) { + // Manuell auf eine nicht vom Sync verwaltete Spalte bewegte Tickets (z. B. qa/fertig) + // werden vom periodischen Codeberg-Sync nicht zurueckgesetzt. + if (!in_array($existing['column_name'], self::SYNC_MANAGED_COLUMNS, true)) { return $existing; } diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index f4b9b3f..4f04db4 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -8,12 +8,23 @@ 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) in Backlog, geschlossene in Done. - * Ticket-Typ und Farbe werden aus dem ersten Codeberg-Label uebernommen (F4: "Ticket-Typen ... - * koennen mit den Codeberg-Farben dargestellt werden"). + * (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"). */ final class CodebergSyncService { + /** + * Reihenfolge = Prioritaet, falls ein Issue mehrere dieser Labels gleichzeitig traegt + * (im normalen Workflow schliessen sie sich gegenseitig aus). + */ + private const LABEL_TO_COLUMN = [ + 'wip' => 'in_arbeit', + 'testing' => 'testing', + 'automate' => 'bereit', + ]; + public function __construct( private readonly CodebergClient $client, private readonly TicketRepository $tickets, @@ -25,7 +36,7 @@ final class CodebergSyncService public function sync(): void { foreach ($this->client->listIssues($this->owner, $this->repo, 'open') as $issue) { - $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'backlog')); + $this->tickets->upsertFromCodeberg($this->mapIssue($issue, $this->columnForOpenIssue($issue))); } foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) { @@ -33,6 +44,25 @@ final class CodebergSyncService } } + /** + * @param array{labels: array} $issue + */ + private function columnForOpenIssue(array $issue): string + { + $labelNames = array_map( + static fn (array $label): string => $label['name'], + $issue['labels'] ?? [] + ); + + foreach (self::LABEL_TO_COLUMN as $label => $column) { + if (in_array($label, $labelNames, true)) { + return $column; + } + } + + return 'backlog'; + } + /** * @param array{number: int, title: string, body: ?string, labels: array} $issue * @return array diff --git a/api/tests/Unit/Repository/TicketRepositoryTest.php b/api/tests/Unit/Repository/TicketRepositoryTest.php index 8fbda6b..2c16673 100644 --- a/api/tests/Unit/Repository/TicketRepositoryTest.php +++ b/api/tests/Unit/Repository/TicketRepositoryTest.php @@ -122,7 +122,7 @@ final class TicketRepositoryTest extends TestCase 'column' => 'backlog', ]); $ticket = $this->repository->findByExternalRef('42'); - $this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']); + $this->repository->update((int) $ticket['id'], ['column' => 'qa']); $result = $this->repository->upsertFromCodeberg([ 'external_ref' => '42', @@ -130,6 +130,23 @@ final class TicketRepositoryTest extends TestCase 'column' => 'backlog', ]); + self::assertSame('qa', $result['column_name']); + } + + public function testUpsertFromCodebergMovesTicketBetweenSyncManagedColumns(): void + { + $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42', + 'column' => 'bereit', + ]); + + $result = $this->repository->upsertFromCodeberg([ + 'external_ref' => '42', + 'title' => 'Issue 42', + 'column' => 'in_arbeit', + ]); + self::assertSame('in_arbeit', $result['column_name']); } diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index 1411db2..845b067 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -87,4 +87,80 @@ final class CodebergSyncServiceTest extends TestCase $stillQa = $tickets->findByExternalRef('1'); self::assertSame('qa', $stillQa['column_name']); } + + /** @return array */ + public static function labelColumnProvider(): array + { + return [ + 'automate -> bereit' => ['automate', 'bereit'], + 'wip -> in_arbeit' => ['wip', 'in_arbeit'], + 'testing -> testing' => ['testing', 'testing'], + ]; + } + + /** @dataProvider labelColumnProvider */ + public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void + { + $tickets = new TicketRepository(Database::inMemory()); + $client = new FakeCodebergClient([ + 'open' => [ + ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]], + ], + 'closed' => [], + ]); + + (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + + $ticket = $tickets->findByExternalRef('1'); + self::assertSame($expectedColumn, $ticket['column_name']); + } + + public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $client = new FakeCodebergClient([ + 'open' => [ + ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]], + ], + 'closed' => [], + ]); + $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + + $service->sync(); + self::assertSame('bereit', $tickets->findByExternalRef('1')['column_name']); + + $client = new FakeCodebergClient([ + 'open' => [ + ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]], + ], + 'closed' => [], + ]); + (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + + self::assertSame('in_arbeit', $tickets->findByExternalRef('1')['column_name']); + } + + public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $client = new FakeCodebergClient([ + 'open' => [ + ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], + ], + 'closed' => [], + ]); + $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'); + $service->sync(); + self::assertSame('testing', $tickets->findByExternalRef('1')['column_name']); + + $client = new FakeCodebergClient([ + 'open' => [], + 'closed' => [ + ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], + ], + ]); + (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync(); + + self::assertSame('done', $tickets->findByExternalRef('1')['column_name']); + } }