Merge pull request 'Codeberg-Sync: offene Issues per Label der richtigen Board-Spalte zuordnen' (#16) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/16
This commit is contained in:
fds2610 2026-07-29 16:49:50 +02:00
commit db92f5126e
5 changed files with 148 additions and 13 deletions

14
API.md
View file

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

View file

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

View file

@ -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<int, array{name: string, color: string}>} $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<int, array{name: string, color: string}>} $issue
* @return array<string, mixed>

View file

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

View file

@ -87,4 +87,80 @@ final class CodebergSyncServiceTest extends TestCase
$stillQa = $tickets->findByExternalRef('1');
self::assertSame('qa', $stillQa['column_name']);
}
/** @return array<int, array{0: string, 1: string}> */
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']);
}
}