>> $issuesByRepoAndState */ public function __construct(private readonly array $issuesByRepoAndState) { } public function listIssues(string $owner, string $repo, string $state): array { return $this->issuesByRepoAndState[$owner . '/' . $repo][$state] ?? []; } } final class CodebergSyncServiceTest extends TestCase { 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]); } /** * 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 $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()); $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]], ], 'closed' => [ ['number' => 2, 'title' => 'Geschlossenes Issue', 'body' => null, 'labels' => []], ], ]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); $backlog = $tickets->all('backlog'); $done = $tickets->all('done'); self::assertCount(1, $backlog); self::assertSame('Offenes Issue', $backlog[0]['title']); self::assertSame('bug', $backlog[0]['type']); 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::assertSame('#fbca04', $done[0]['color']); } public function testSyncKeepsIssuesFromDifferentProjectsSeparateAndColorsThemPerProject(): void { $tickets = new TicketRepository(Database::inMemory()); $client = new FakeCodebergClient([ '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' => [], ], ]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([ self::MONITOR_REPO, ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], ])))->sync(); $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 testSyncClearsColorOfTicketsFromRemovedProject(): void { $tickets = new TicketRepository(Database::inMemory()); $client = self::clientForMonitor([ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); self::assertSame('#fbca04', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['color']); // Projekt wurde im Projekte-Modal entfernt (Issue #34): der naechste Sync-Lauf sieht // es nicht mehr in repoConfigsFor() und darf das verwaiste Ticket nicht laenger blau // faerben, obwohl es kein Projekt mit dieser Farbe mehr gibt. $client = new FakeCodebergClient([]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([])))->sync(); self::assertNull($tickets->findByExternalRef('1', self::MONITOR_SLUG)['color']); } public function testSyncDoesNotResetManuallyMovedTicket(): void { $tickets = new TicketRepository(Database::inMemory()); $client = self::clientForMonitor([ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); $service->sync(); $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); $tickets->update((int) $ticket['id'], ['column' => 'fertig']); $service->sync(); $stillFertig = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertSame('fertig', $stillFertig['column_name']); } /** @return array */ public static function labelColumnProvider(): array { return [ 'automate -> bereit' => ['automate', 'bereit'], 'wip -> in_arbeit' => ['wip', 'in_arbeit'], 'testing -> testing' => ['testing', 'testing'], 'help wanted -> qa' => ['help wanted', 'qa'], ]; } /** @dataProvider labelColumnProvider */ public function testSyncMapsOpenIssueLabelToBoardColumn(string $label, string $expectedColumn): void { $tickets = new TicketRepository(Database::inMemory()); $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => $label, 'color' => 'ff0000']]], ], 'closed' => [], ]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertSame($expectedColumn, $ticket['column_name']); } public function testSyncMovesTicketBetweenAutomatedColumnsWhenLabelChanges(): void { $tickets = new TicketRepository(Database::inMemory()); $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'automate', 'color' => 'ff0000']]], ], 'closed' => [], ]); $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); $service->sync(); self::assertSame('bereit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'wip', 'color' => 'ff0000']]], ], 'closed' => [], ]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void { $tickets = new TicketRepository(Database::inMemory()); $client = self::clientForMonitor([ 'open' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], 'closed' => [], ]); $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); $service->sync(); self::assertSame('testing', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); $client = self::clientForMonitor([ 'open' => [], 'closed' => [ ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], ]); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); self::assertSame('done', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } }