From ec8c74c0f82d7a6306eb0fb3683d096c98268a01 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Fri, 21 Aug 2026 11:57:08 +0200 Subject: [PATCH] Neue KPI: offene PRs ueber alle Projekte (Issue #59) CodebergSyncService berechnet am Ende jedes Sync-Laufs (periodisch und webhook-getriggert, auch bei PR-Aenderungen) die Summe der offenen Pull Requests aller konfigurierten Projekte und speichert sie als KPI "open_prs" im bestehenden generischen KPI-Repository - sie erscheint damit automatisch im KPI-Monitor der Wand und im KPI-Modal. Co-Authored-By: Claude Sonnet 5 --- API.md | 7 ++ api/bin/sync-codeberg.php | 2 + api/src/Application.php | 2 +- api/src/Sync/CodebergClient.php | 6 ++ api/src/Sync/CodebergSyncService.php | 24 +++++ api/src/Sync/CurlCodebergClient.php | 25 +++++ .../Controller/RepoConfigControllerTest.php | 5 + .../Unit/Controller/TicketControllerTest.php | 30 +++++- .../Unit/Sync/CodebergSyncServiceTest.php | 96 +++++++++++++++---- .../Unit/Sync/CurlCodebergClientTest.php | 30 ++++++ api/tests/Unit/Sync/RepoHealthCheckerTest.php | 5 + 11 files changed, 212 insertions(+), 20 deletions(-) diff --git a/API.md b/API.md index a975928..1673d2f 100644 --- a/API.md +++ b/API.md @@ -293,6 +293,13 @@ siehe Issue #38. der Wert ueber 0 liegt - eine dauerhaft rote Kachel mit einer 0 waere genau das Rauschen, das den Ernstfall untergehen liesse. +Issue #59: die Kennzahl `open_prs` ("Offene PRs") wird nicht per `POST /api/v1/kpis/{key}` +gemeldet, sondern am Ende jedes Sync-Laufs (`CodebergSyncService::sync()`/`resync()`) automatisch +aus der Summe der offenen Pull Requests aller konfigurierten Projekte berechnet und ueber das +generische KPI-Repository gespeichert. Ein Sync-Lauf startet u.a. bei jedem eingehenden +Codeberg-Webhook (auch PR-Aenderungen) sowie periodisch, siehe +[`POST /api/v1/webhooks/codeberg`](#post-apiv1webhookscodeberg--sofort-sync-per-webhook). + ### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden Auth: `X-Api-Key`. diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php index c0683fe..4dcabc0 100755 --- a/api/bin/sync-codeberg.php +++ b/api/bin/sync-codeberg.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Monitor\Api\Config; use Monitor\Api\Db\Database; +use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergSyncService; @@ -21,6 +22,7 @@ $service = new CodebergSyncService( new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()), new TicketRepository($db), new RepoConfigRepository($db), + new KpiRepository($db), ); // Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README). diff --git a/api/src/Application.php b/api/src/Application.php index 9a94495..e4a8117 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -68,7 +68,7 @@ final class Application // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. $codebergClient = new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()); - $syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs); + $syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs, $kpis); $ticketController = new TicketController($tickets, $syncService); $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); diff --git a/api/src/Sync/CodebergClient.php b/api/src/Sync/CodebergClient.php index 95c8f27..a179911 100644 --- a/api/src/Sync/CodebergClient.php +++ b/api/src/Sync/CodebergClient.php @@ -32,4 +32,10 @@ interface CodebergClient * @return array */ public function listWebhooks(string $owner, string $repo): array; + + /** + * Issue #59: Anzahl offener Pull Requests eines Projekts, Grundlage fuer den + * projektuebergreifenden "Offene PRs"-KPI (CodebergSyncService). + */ + public function countOpenPullRequests(string $owner, string $repo): int; } diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 6f1fc3e..0488d5b 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Monitor\Api\Sync; +use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; @@ -33,6 +34,7 @@ final class CodebergSyncService private readonly CodebergClient $client, private readonly TicketRepository $tickets, private readonly RepoConfigRepository $repoConfigs, + private readonly KpiRepository $kpis, ) { } @@ -53,6 +55,8 @@ final class CodebergSyncService // oben nicht mehr angefasst und wuerden sonst dauerhaft in der alten, nicht mehr // zutreffenden Projektfarbe angezeigt. $this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs)); + + $this->reportOpenPullRequestKpi($repoConfigs); } /** @@ -76,6 +80,26 @@ final class CodebergSyncService } $this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs)); + + $this->reportOpenPullRequestKpi($repoConfigs); + } + + /** + * Issue #59: projektuebergreifende Summe offener PRs als KPI (F7). Laeuft am Ende von + * sync()/resync() statt direkt im Webhook-Handler, weil dieser Payloads bewusst nicht + * auswertet (siehe WebhookController) - ein PR-Webhook loest ohnehin denselben Sync-Lauf + * aus wie jeder andere, der neue Wert ist danach also aktuell. + * + * @param array $repoConfigs + */ + private function reportOpenPullRequestKpi(array $repoConfigs): void + { + $openPrCount = array_sum(array_map( + fn (array $repoConfig): int => $this->client->countOpenPullRequests($repoConfig['owner'], $repoConfig['repo']), + $repoConfigs + )); + + $this->kpis->report('open_prs', (float) $openPrCount, 'Offene PRs', null, null); } /** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */ diff --git a/api/src/Sync/CurlCodebergClient.php b/api/src/Sync/CurlCodebergClient.php index 6ab2b47..bd5672a 100644 --- a/api/src/Sync/CurlCodebergClient.php +++ b/api/src/Sync/CurlCodebergClient.php @@ -60,6 +60,31 @@ final class CurlCodebergClient implements CodebergClient return $issues; } + public function countOpenPullRequests(string $owner, string $repo): int + { + $count = 0; + + for ($page = 1; $page <= self::MAX_PAGES; $page++) { + $url = sprintf( + '%s/repos/%s/%s/issues?state=open&type=pulls&page=%d&limit=%d', + $this->apiBase, + rawurlencode($owner), + rawurlencode($repo), + $page, + self::PER_PAGE + ); + + $pagePulls = $this->fetch($url); + $count += count($pagePulls); + + if (count($pagePulls) < self::PER_PAGE) { + break; + } + } + + return $count; + } + public function repoPermissions(string $owner, string $repo): array { $url = sprintf('%s/repos/%s/%s', $this->apiBase, rawurlencode($owner), rawurlencode($repo)); diff --git a/api/tests/Unit/Controller/RepoConfigControllerTest.php b/api/tests/Unit/Controller/RepoConfigControllerTest.php index 8604bc5..29ebb22 100644 --- a/api/tests/Unit/Controller/RepoConfigControllerTest.php +++ b/api/tests/Unit/Controller/RepoConfigControllerTest.php @@ -40,6 +40,11 @@ final class RepoConfigControllerTest extends TestCase { return [['active' => true, 'url' => 'https://monitor.example.org/api/v1/webhooks/codeberg', 'content_type' => 'json']]; } + + public function countOpenPullRequests(string $owner, string $repo): int + { + return 0; + } }); $this->controller = new RepoConfigController(new RepoConfigRepository($db), $this->tickets, $healthChecker); } diff --git a/api/tests/Unit/Controller/TicketControllerTest.php b/api/tests/Unit/Controller/TicketControllerTest.php index 616818a..ce50822 100644 --- a/api/tests/Unit/Controller/TicketControllerTest.php +++ b/api/tests/Unit/Controller/TicketControllerTest.php @@ -8,6 +8,7 @@ use Monitor\Api\Controller\TicketController; use Monitor\Api\Db\Database; use Monitor\Api\Http\NotFoundException; use Monitor\Api\Http\Request; +use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergClient; @@ -30,6 +31,21 @@ final class StubCodebergClient implements CodebergClient return []; } + + 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 0; + } } final class TicketControllerTest extends TestCase @@ -42,7 +58,12 @@ final class TicketControllerTest extends TestCase $this->repository = new TicketRepository(Database::inMemory()); $this->controller = new TicketController( $this->repository, - new CodebergSyncService(new StubCodebergClient(), $this->repository, new RepoConfigRepository(Database::inMemory())) + new CodebergSyncService( + new StubCodebergClient(), + $this->repository, + new RepoConfigRepository(Database::inMemory()), + new KpiRepository(Database::inMemory()) + ) ); } @@ -124,7 +145,12 @@ final class TicketControllerTest extends TestCase $repository = new TicketRepository(Database::inMemory()); $controller = new TicketController( $repository, - new CodebergSyncService(new StubCodebergClient(shouldFail: true), $repository, new RepoConfigRepository(Database::inMemory())) + new CodebergSyncService( + new StubCodebergClient(shouldFail: true), + $repository, + new RepoConfigRepository(Database::inMemory()), + new KpiRepository(Database::inMemory()) + ) ); $response = $controller->resync(Request::create('POST', '/api/v1/tickets/resync')); diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index 02e8454..e50437c 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Monitor\Api\Tests\Sync; use Monitor\Api\Db\Database; +use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergClient; @@ -13,15 +14,35 @@ use PHPUnit\Framework\TestCase; final class FakeCodebergClient implements CodebergClient { - /** @param array>> $issuesByRepoAndState */ - public function __construct(private readonly array $issuesByRepoAndState) - { + /** + * @param array>> $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 @@ -55,6 +76,12 @@ final class CodebergSyncServiceTest extends TestCase 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()); @@ -67,7 +94,7 @@ final class CodebergSyncServiceTest extends TestCase ], ]); - (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]), self::kpis()))->sync(); $backlog = $tickets->all('backlog'); $done = $tickets->all('done'); @@ -102,7 +129,7 @@ final class CodebergSyncServiceTest extends TestCase (new CodebergSyncService($client, $tickets, self::repoConfigsFor([ self::MONITOR_REPO, ['owner' => 'fds2610', 'repo' => 'Steuerkram', 'color' => '#28a745'], - ])))->sync(); + ]), self::kpis()))->sync(); $backlog = $tickets->all('backlog'); self::assertCount(2, $backlog, 'gleiche Issue-Nummer aus verschiedenen Projekten darf nicht zu einem Ticket verschmelzen'); @@ -125,14 +152,14 @@ final class CodebergSyncServiceTest extends TestCase 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); + (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([])))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([]), self::kpis()))->sync(); self::assertNull($tickets->findByExternalRef('1', self::MONITOR_SLUG)['color']); } @@ -144,7 +171,7 @@ final class CodebergSyncServiceTest extends TestCase 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); + $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]), self::kpis()); $service->sync(); $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); @@ -178,7 +205,7 @@ final class CodebergSyncServiceTest extends TestCase 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); + (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']); @@ -193,7 +220,7 @@ final class CodebergSyncServiceTest extends TestCase ], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); + $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']); @@ -204,7 +231,7 @@ final class CodebergSyncServiceTest extends TestCase ], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]), self::kpis()))->sync(); self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } @@ -217,7 +244,7 @@ final class CodebergSyncServiceTest extends TestCase 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, $repoConfigs))->sync(); + (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. @@ -225,7 +252,7 @@ final class CodebergSyncServiceTest extends TestCase // 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(); + (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'])); @@ -239,13 +266,13 @@ final class CodebergSyncServiceTest extends TestCase 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], 'closed' => [], ]); - (new CodebergSyncService($client, $tickets, $repoConfigs))->sync(); + (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))->resync(); + (new CodebergSyncService($client, $tickets, $repoConfigs, self::kpis()))->resync(); $stillFertig = $tickets->findByExternalRef('1', self::MONITOR_SLUG); self::assertNotNull($stillFertig); @@ -261,7 +288,7 @@ final class CodebergSyncServiceTest extends TestCase ], 'closed' => [], ]); - $service = new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])); + $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']); @@ -271,8 +298,43 @@ final class CodebergSyncServiceTest extends TestCase ['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => [['name' => 'testing', 'color' => 'ff0000']]], ], ]); - (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO])))->sync(); + (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']); + } } diff --git a/api/tests/Unit/Sync/CurlCodebergClientTest.php b/api/tests/Unit/Sync/CurlCodebergClientTest.php index fa33c64..7fdc2b2 100644 --- a/api/tests/Unit/Sync/CurlCodebergClientTest.php +++ b/api/tests/Unit/Sync/CurlCodebergClientTest.php @@ -185,5 +185,35 @@ namespace Monitor\Api\Tests\Sync { self::assertSame(403, $e->status); } } + + public function testCountOpenPullRequestsCountsSinglePage(): void + { + CurlStub::$queue = [ + ['status' => 200, 'body' => json_encode([ + ['number' => 1, 'title' => 'PR A'], + ['number' => 2, 'title' => 'PR B'], + ])], + ]; + + $count = (new CurlCodebergClient('https://codeberg.org/api/v1'))->countOpenPullRequests('fds2610', 'monitor'); + + self::assertSame(2, $count); + self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]); + self::assertStringContainsString('type=pulls', CurlStub::$requestedUrls[0]); + } + + public function testCountOpenPullRequestsSumsAcrossPages(): void + { + $fullPage = array_fill(0, 50, ['number' => 1, 'title' => 'PR']); + CurlStub::$queue = [ + ['status' => 200, 'body' => json_encode($fullPage)], + ['status' => 200, 'body' => json_encode([['number' => 99, 'title' => 'Letzte PR']])], + ]; + + $count = (new CurlCodebergClient('https://codeberg.org/api/v1'))->countOpenPullRequests('fds2610', 'monitor'); + + self::assertSame(51, $count); + self::assertCount(2, CurlStub::$requestedUrls); + } } } diff --git a/api/tests/Unit/Sync/RepoHealthCheckerTest.php b/api/tests/Unit/Sync/RepoHealthCheckerTest.php index 0268562..c0afc7f 100644 --- a/api/tests/Unit/Sync/RepoHealthCheckerTest.php +++ b/api/tests/Unit/Sync/RepoHealthCheckerTest.php @@ -125,6 +125,11 @@ final class RepoHealthCheckerTest extends TestCase return $this->listWebhooks ?? []; } + + public function countOpenPullRequests(string $owner, string $repo): int + { + return 0; + } }; } }