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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-21 11:57:08 +02:00
parent 988a145e31
commit ec8c74c0f8
11 changed files with 212 additions and 20 deletions

7
API.md
View file

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

View file

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

View file

@ -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());

View file

@ -32,4 +32,10 @@ interface CodebergClient
* @return array<int, array{active: bool, url: string, content_type: string}>
*/
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;
}

View file

@ -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<int, array{owner: string, repo: string}> $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. */

View file

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

View file

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

View file

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

View file

@ -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<string, array<string, array<int, array{number:int,title:string,body:?string,labels:array}>>> $issuesByRepoAndState */
public function __construct(private readonly array $issuesByRepoAndState)
{
/**
* @param array<string, array<string, array<int, array{number:int,title:string,body:?string,labels:array}>>> $issuesByRepoAndState
* @param array<string, int> $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']);
}
}

View file

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

View file

@ -125,6 +125,11 @@ final class RepoHealthCheckerTest extends TestCase
return $this->listWebhooks ?? [];
}
public function countOpenPullRequests(string $owner, string $repo): int
{
return 0;
}
};
}
}