Manchmal werden bei Codeberg geloeschte oder anderweitig verschwundene Issues vom periodischen Sync nicht bereinigt (er upsertet nur, ruehrt fehlende Issues nie an). Neuer Knopf neben der "Kanban-Board"-Ueberschrift stoesst dafuer einen synchronen Voll-Resync an (POST /api/v1/tickets/resync, X-Admin-Key): jedes konfigurierte Projekt wird neu gelesen, Tickets zu inzwischen verschwundenen Issues werden je Projekt entfernt (auch wenn sie manuell auf eine nicht vom Sync verwaltete Spalte wie "fertig" bewegt wurden), Tickets zu weiterhin existierenden Issues bleiben unangetastet. Der PO geht dabei sichtbar ans Board und steht dort, bis die Anfrage abgeschlossen ist - anders als beim automatischen Board-Besuch (feste Animationsdauer je Stage) macht das die tatsaechliche Sync-Dauer sichtbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
278 lines
12 KiB
PHP
278 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Monitor\Api\Tests\Sync;
|
|
|
|
use Monitor\Api\Db\Database;
|
|
use Monitor\Api\Repository\RepoConfigRepository;
|
|
use Monitor\Api\Repository\TicketRepository;
|
|
use Monitor\Api\Sync\CodebergClient;
|
|
use Monitor\Api\Sync\CodebergSyncService;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class FakeCodebergClient implements CodebergClient
|
|
{
|
|
/** @param array<string, array<string, array<int, array{number:int,title:string,body:?string,labels:array}>>> $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<string, array<int, array{number:int,title:string,body:?string,labels: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<int, array{owner: string, repo: string, color: string}> $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<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'],
|
|
'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 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))->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))->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))->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))->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]));
|
|
$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']);
|
|
}
|
|
}
|