>> $issuesByRepoAndState * @param array $openPullRequestsByRepo */ public function __construct( private readonly array $issuesByRepoAndState, private readonly array $openPullRequestsByRepo = [], ) { } public function listIssues(string $owner, string $repo, string $state): array { return $this->issuesByRepoAndState[$owner . '/' . $repo][$state] ?? []; } public function repoPermissions(string $owner, string $repo): array { return ['push' => true]; } public function listWebhooks(string $owner, string $repo): array { return []; } public function countOpenPullRequests(string $owner, string $repo): int { return $this->openPullRequestsByRepo[$owner . '/' . $repo] ?? 0; } } 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; } /** Frisches, unabhaengiges KpiRepository - der KPI-Wert selbst ist nicht Gegenstand der meisten Tests hier. */ private static function kpis(): KpiRepository { return new KpiRepository(Database::inMemory()); } 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]), self::kpis()))->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'], ]), self::kpis()))->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]), self::kpis()))->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([]), self::kpis()))->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]), self::kpis()); $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]), self::kpis()))->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]), self::kpis()); $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]), self::kpis()))->sync(); self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } public function testResyncRemovesTicketsThatNoLongerExistAtCodeberg(): void { $tickets = new TicketRepository(Database::inMemory()); $repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]); $client = self::clientForMonitor([ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); (new CodebergSyncService($client, $tickets, $repoConfigs, self::kpis()))->sync(); // Manuell auf "fertig" bewegt, damit sync() es normalerweise unangetastet liesse // (siehe testSyncDoesNotResetManuallyMovedTicket) - Issue #51 ist genau fuer solche // liegen gebliebenen Karteileichen gedacht, die der normale Sync nicht mehr anfasst. $orphanTicket = $tickets->create(['title' => 'Verwaist', 'repo' => self::MONITOR_SLUG, 'external_ref' => '2', 'column' => 'fertig']); // Issue 1 wurde bei Codeberg inzwischen komplett geloescht (taucht weder offen noch // geschlossen auf) - genau der Fall aus Issue #51, den der normale Sync nicht bereinigt. (new CodebergSyncService(new FakeCodebergClient([self::MONITOR_SLUG => ['open' => [], 'closed' => []]]), $tickets, $repoConfigs, self::kpis()))->resync(); self::assertNull($tickets->findByExternalRef('1', self::MONITOR_SLUG)); self::assertNull($tickets->find((int) $orphanTicket['id'])); } public function testResyncKeepsManuallyMovedTicketWhoseIssueStillExistsAtCodeberg(): void { $tickets = new TicketRepository(Database::inMemory()); $repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]); $client = self::clientForMonitor([ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); (new CodebergSyncService($client, $tickets, $repoConfigs, self::kpis()))->sync(); $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); $tickets->update((int) $ticket['id'], ['column' => 'fertig']); // Dasselbe Issue existiert bei Codeberg weiterhin (offen) - resync() darf es trotz // manuell gesetzter, nicht vom Sync verwalteter Spalte nicht als Karteileiche werfen. (new CodebergSyncService($client, $tickets, $repoConfigs, self::kpis()))->resync(); $stillFertig = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertNotNull($stillFertig); self::assertSame('fertig', $stillFertig['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]), self::kpis()); $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]), self::kpis()))->sync(); self::assertSame('done', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } public function testSyncReportsOpenPullRequestCountAcrossProjectsAsKpi(): void { $tickets = new TicketRepository(Database::inMemory()); $client = new FakeCodebergClient( issuesByRepoAndState: [], openPullRequestsByRepo: ['fds2610/monitor' => 2, 'fds2610/Steuerkram' => 3], ); $kpis = self::kpis(); (new CodebergSyncService($client, $tickets, self::repoConfigsFor([ self::MONITOR_REPO, ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], ]), $kpis))->sync(); $kpi = $kpis->find('open_prs'); self::assertNotNull($kpi); self::assertSame(5.0, $kpi['current_value']); self::assertSame('Offene PRs', $kpi['label']); } public function testResyncAlsoRefreshesOpenPullRequestKpi(): void { $tickets = new TicketRepository(Database::inMemory()); $repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]); $kpis = self::kpis(); $client = new FakeCodebergClient( issuesByRepoAndState: [self::MONITOR_SLUG => ['open' => [], 'closed' => []]], openPullRequestsByRepo: [self::MONITOR_SLUG => 4], ); (new CodebergSyncService($client, $tickets, $repoConfigs, $kpis))->resync(); self::assertSame(4.0, $kpis->find('open_prs')['current_value']); } /** @return array{string, SyncTrigger} */ private static function deployTrigger(): array { $path = sys_get_temp_dir() . '/deploy-trigger-' . bin2hex(random_bytes(4)); return [$path, new SyncTrigger($path)]; } private static function syncWithOpenPrs(int $openPrs, KpiRepository $kpis, SyncTrigger $trigger): void { $client = new FakeCodebergClient( issuesByRepoAndState: [self::MONITOR_SLUG => ['open' => [], 'closed' => []]], openPullRequestsByRepo: [self::MONITOR_SLUG => $openPrs], ); (new CodebergSyncService($client, new TicketRepository(Database::inMemory()), self::repoConfigsFor([self::MONITOR_REPO]), $kpis, $trigger))->sync(); } public function testRequestsDeployCheckWhenOpenPullRequestCountDrops(): void { [$path, $trigger] = self::deployTrigger(); $kpis = self::kpis(); self::syncWithOpenPrs(1, $kpis, $trigger); self::assertFileDoesNotExist($path, 'Erster Lauf ohne Vorwert darf nichts ausloesen.'); self::syncWithOpenPrs(0, $kpis, $trigger); self::assertFileExists($path); @unlink($path); } public function testDoesNotRequestDeployCheckWhenOpenPullRequestCountStaysOrRises(): void { [$path, $trigger] = self::deployTrigger(); $kpis = self::kpis(); self::syncWithOpenPrs(1, $kpis, $trigger); self::syncWithOpenPrs(1, $kpis, $trigger); self::syncWithOpenPrs(2, $kpis, $trigger); self::assertFileDoesNotExist($path); } }