Offene PRs als Tickets in der Spalte Fertig darstellen
PRs werden beim Sync als Tickets (is_pull_request) angelegt, mit Projektfarbe, schwarzen Querstreifen und PR-Nummer; im Spalten-Modal mit Link auf den PR. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9c87840253
commit
f14d851410
15 changed files with 231 additions and 40 deletions
2
API.md
2
API.md
|
|
@ -301,7 +301,7 @@ Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done
|
||||||
|
|
||||||
Query-Parameter: `column` (optional, filtert auf eine Spalte).
|
Query-Parameter: `column` (optional, filtert auf eine Spalte).
|
||||||
|
|
||||||
Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }`
|
Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "is_pull_request", "created_at", "updated_at" }, ... ] }`
|
||||||
|
|
||||||
### `POST /api/v1/tickets` — Ticket anlegen
|
### `POST /api/v1/tickets` — Ticket anlegen
|
||||||
|
|
||||||
|
|
|
||||||
3
api/src/Db/migrations/009_ticket_pull_requests.sql
Normal file
3
api/src/Db/migrations/009_ticket_pull_requests.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Offene Pull Requests erscheinen als Tickets in der Spalte "fertig". Issues und PRs teilen
|
||||||
|
-- sich bei Codeberg/Forgejo einen Nummernraum je Projekt, external_ref bleibt also eindeutig.
|
||||||
|
ALTER TABLE tickets ADD COLUMN is_pull_request INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
@ -33,8 +33,8 @@ final class TicketRepository
|
||||||
|
|
||||||
$now = $this->db->now();
|
$now = $this->db->now();
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->db->pdo()->prepare(
|
||||||
'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at)
|
'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, is_pull_request, created_at, updated_at)
|
||||||
VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)'
|
VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :is_pull_request, :created_at, :updated_at)'
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
'external_ref' => $data['external_ref'] ?? null,
|
'external_ref' => $data['external_ref'] ?? null,
|
||||||
|
|
@ -46,6 +46,7 @@ final class TicketRepository
|
||||||
'color' => $data['color'] ?? null,
|
'color' => $data['color'] ?? null,
|
||||||
'assignee_agent_id' => $data['assignee_agent_id'] ?? null,
|
'assignee_agent_id' => $data['assignee_agent_id'] ?? null,
|
||||||
'source' => $data['source'] ?? 'api',
|
'source' => $data['source'] ?? 'api',
|
||||||
|
'is_pull_request' => empty($data['is_pull_request']) ? 0 : 1,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
@ -170,6 +171,41 @@ final class TicketRepository
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt das Ticket zu einem offenen Pull Request in der Spalte "fertig" an bzw. haelt Titel
|
||||||
|
* und Beschreibung aktuell. Die Spalte bleibt bei einem bestehenden Ticket unberuehrt.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function upsertPullRequest(array $data): array
|
||||||
|
{
|
||||||
|
$existing = $this->findByExternalRef((string) $data['external_ref'], $data['repo'] ?? null);
|
||||||
|
if ($existing === null) {
|
||||||
|
return $this->create($data + ['column' => 'fertig', 'source' => 'codeberg', 'is_pull_request' => 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->update((int) $existing['id'], [
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'color' => $data['color'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt PR-Tickets eines Projekts, deren PR nicht mehr offen ist (gemerged/geschlossen).
|
||||||
|
*
|
||||||
|
* @param string[] $openExternalRefs
|
||||||
|
*/
|
||||||
|
public function deleteClosedPullRequests(string $repo, array $openExternalRefs): void
|
||||||
|
{
|
||||||
|
$sql = 'DELETE FROM tickets WHERE repo = ? AND is_pull_request = 1';
|
||||||
|
if ($openExternalRefs !== []) {
|
||||||
|
$sql .= ' AND external_ref NOT IN (' . implode(', ', array_fill(0, count($openExternalRefs), '?')) . ')';
|
||||||
|
}
|
||||||
|
$this->db->pdo()->prepare($sql)->execute([$repo, ...$openExternalRefs]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loescht die Projektfarbe von Tickets, deren Quell-Projekt nicht (mehr) in $slugs steht
|
* Loescht die Projektfarbe von Tickets, deren Quell-Projekt nicht (mehr) in $slugs steht
|
||||||
* (Issue #34: nach Entfernen/Umbenennen eines Projekts im Projekte-Modal blieben dessen
|
* (Issue #34: nach Entfernen/Umbenennen eines Projekts im Projekte-Modal blieben dessen
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,10 @@ interface CodebergClient
|
||||||
public function listWebhooks(string $owner, string $repo): array;
|
public function listWebhooks(string $owner, string $repo): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #59: Anzahl offener Pull Requests eines Projekts, Grundlage fuer den
|
* Offene Pull Requests eines Projekts. Grundlage fuer den projektuebergreifenden
|
||||||
* projektuebergreifenden "Offene PRs"-KPI (CodebergSyncService).
|
* "Offene PRs"-KPI (Issue #59) und die PR-Tickets in der Spalte "fertig".
|
||||||
|
*
|
||||||
|
* @return array<int, array{number: int, title: string, body: ?string}>
|
||||||
*/
|
*/
|
||||||
public function countOpenPullRequests(string $owner, string $repo): int;
|
public function listOpenPullRequests(string $owner, string $repo): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,13 @@ final class CodebergSyncService
|
||||||
public function sync(): void
|
public function sync(): void
|
||||||
{
|
{
|
||||||
$repoConfigs = $this->repoConfigs->all();
|
$repoConfigs = $this->repoConfigs->all();
|
||||||
|
$openPrCount = 0;
|
||||||
|
|
||||||
foreach ($repoConfigs as $repoConfig) {
|
foreach ($repoConfigs as $repoConfig) {
|
||||||
$this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
$this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
||||||
|
$openPrCount += count(
|
||||||
|
$this->syncPullRequests($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #34: Tickets aus zwischenzeitlich entfernten oder umbenannten Projekten werden
|
// Issue #34: Tickets aus zwischenzeitlich entfernten oder umbenannten Projekten werden
|
||||||
|
|
@ -57,7 +61,7 @@ final class CodebergSyncService
|
||||||
// zutreffenden Projektfarbe angezeigt.
|
// zutreffenden Projektfarbe angezeigt.
|
||||||
$this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs));
|
$this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs));
|
||||||
|
|
||||||
$this->reportOpenPullRequestKpi($repoConfigs);
|
$this->reportOpenPullRequestKpi($openPrCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,15 +78,19 @@ final class CodebergSyncService
|
||||||
{
|
{
|
||||||
$repoConfigs = $this->repoConfigs->all();
|
$repoConfigs = $this->repoConfigs->all();
|
||||||
|
|
||||||
|
$openPrCount = 0;
|
||||||
|
|
||||||
foreach ($repoConfigs as $repoConfig) {
|
foreach ($repoConfigs as $repoConfig) {
|
||||||
$slug = $repoConfig['owner'] . '/' . $repoConfig['repo'];
|
$slug = $repoConfig['owner'] . '/' . $repoConfig['repo'];
|
||||||
$seenExternalRefs = $this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
$seenExternalRefs = $this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
||||||
$this->tickets->deleteOrphansForRepo($slug, $seenExternalRefs);
|
$pullRequestRefs = $this->syncPullRequests($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
||||||
|
$openPrCount += count($pullRequestRefs);
|
||||||
|
$this->tickets->deleteOrphansForRepo($slug, [...$seenExternalRefs, ...$pullRequestRefs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs));
|
$this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs));
|
||||||
|
|
||||||
$this->reportOpenPullRequestKpi($repoConfigs);
|
$this->reportOpenPullRequestKpi($openPrCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,16 +98,9 @@ final class CodebergSyncService
|
||||||
* sync()/resync() statt direkt im Webhook-Handler, weil dieser Payloads bewusst nicht
|
* sync()/resync() statt direkt im Webhook-Handler, weil dieser Payloads bewusst nicht
|
||||||
* auswertet (siehe WebhookController) - ein PR-Webhook loest ohnehin denselben Sync-Lauf
|
* auswertet (siehe WebhookController) - ein PR-Webhook loest ohnehin denselben Sync-Lauf
|
||||||
* aus wie jeder andere, der neue Wert ist danach also aktuell.
|
* 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
|
private function reportOpenPullRequestKpi(int $openPrCount): void
|
||||||
{
|
{
|
||||||
$openPrCount = array_sum(array_map(
|
|
||||||
fn (array $repoConfig): int => $this->client->countOpenPullRequests($repoConfig['owner'], $repoConfig['repo']),
|
|
||||||
$repoConfigs
|
|
||||||
));
|
|
||||||
|
|
||||||
$previous = $this->kpis->find('open_prs')['current_value'] ?? null;
|
$previous = $this->kpis->find('open_prs')['current_value'] ?? null;
|
||||||
|
|
||||||
$this->kpis->report('open_prs', (float) $openPrCount, 'Offene PRs', null, null);
|
$this->kpis->report('open_prs', (float) $openPrCount, 'Offene PRs', null, null);
|
||||||
|
|
@ -112,6 +113,33 @@ final class CodebergSyncService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt fuer jeden offenen PR ein Ticket in der Spalte "fertig" an und entfernt die Tickets
|
||||||
|
* inzwischen gemergter/geschlossener PRs.
|
||||||
|
*
|
||||||
|
* @return string[] external_refs der offenen PRs.
|
||||||
|
*/
|
||||||
|
private function syncPullRequests(string $owner, string $repo, string $color): array
|
||||||
|
{
|
||||||
|
$slug = $owner . '/' . $repo;
|
||||||
|
$refs = [];
|
||||||
|
|
||||||
|
foreach ($this->client->listOpenPullRequests($owner, $repo) as $pull) {
|
||||||
|
$this->tickets->upsertPullRequest([
|
||||||
|
'external_ref' => (string) $pull['number'],
|
||||||
|
'repo' => $slug,
|
||||||
|
'title' => $pull['title'],
|
||||||
|
'description' => $pull['body'],
|
||||||
|
'color' => $color,
|
||||||
|
]);
|
||||||
|
$refs[] = (string) $pull['number'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tickets->deleteClosedPullRequests($slug, $refs);
|
||||||
|
|
||||||
|
return $refs;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */
|
/** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */
|
||||||
private function syncRepo(string $owner, string $repo, string $color): array
|
private function syncRepo(string $owner, string $repo, string $color): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ final class CurlCodebergClient implements CodebergClient
|
||||||
return $issues;
|
return $issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countOpenPullRequests(string $owner, string $repo): int
|
public function listOpenPullRequests(string $owner, string $repo): array
|
||||||
{
|
{
|
||||||
$count = 0;
|
$pulls = [];
|
||||||
|
|
||||||
for ($page = 1; $page <= self::MAX_PAGES; $page++) {
|
for ($page = 1; $page <= self::MAX_PAGES; $page++) {
|
||||||
$url = sprintf(
|
$url = sprintf(
|
||||||
|
|
@ -75,14 +75,21 @@ final class CurlCodebergClient implements CodebergClient
|
||||||
);
|
);
|
||||||
|
|
||||||
$pagePulls = $this->fetch($url);
|
$pagePulls = $this->fetch($url);
|
||||||
$count += count($pagePulls);
|
|
||||||
|
foreach ($pagePulls as $pull) {
|
||||||
|
$pulls[] = [
|
||||||
|
'number' => (int) $pull['number'],
|
||||||
|
'title' => (string) ($pull['title'] ?? ''),
|
||||||
|
'body' => isset($pull['body']) ? (string) $pull['body'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (count($pagePulls) < self::PER_PAGE) {
|
if (count($pagePulls) < self::PER_PAGE) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $count;
|
return $pulls;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function repoPermissions(string $owner, string $repo): array
|
public function repoPermissions(string $owner, string $repo): array
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ final class RepoConfigControllerTest extends TestCase
|
||||||
return [['active' => true, 'url' => 'https://monitor.example.org/api/v1/webhooks/codeberg', 'content_type' => 'json']];
|
return [['active' => true, 'url' => 'https://monitor.example.org/api/v1/webhooks/codeberg', 'content_type' => 'json']];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countOpenPullRequests(string $owner, string $repo): int
|
public function listOpenPullRequests(string $owner, string $repo): array
|
||||||
{
|
{
|
||||||
return 0;
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$this->controller = new RepoConfigController(new RepoConfigRepository($db), $this->tickets, $healthChecker);
|
$this->controller = new RepoConfigController(new RepoConfigRepository($db), $this->tickets, $healthChecker);
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@ final class StubCodebergClient implements CodebergClient
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countOpenPullRequests(string $owner, string $repo): int
|
public function listOpenPullRequests(string $owner, string $repo): array
|
||||||
{
|
{
|
||||||
return 0;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,14 @@ final class FakeCodebergClient implements CodebergClient
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countOpenPullRequests(string $owner, string $repo): int
|
public function listOpenPullRequests(string $owner, string $repo): array
|
||||||
{
|
{
|
||||||
return $this->openPullRequestsByRepo[$owner . '/' . $repo] ?? 0;
|
$pulls = [];
|
||||||
|
for ($i = 1; $i <= ($this->openPullRequestsByRepo[$owner . '/' . $repo] ?? 0); $i++) {
|
||||||
|
$pulls[] = ['number' => 100 + $i, 'title' => 'PR ' . $i, 'body' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pulls;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,6 +344,43 @@ final class CodebergSyncServiceTest extends TestCase
|
||||||
self::assertSame(4.0, $kpis->find('open_prs')['current_value']);
|
self::assertSame(4.0, $kpis->find('open_prs')['current_value']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSyncCreatesFertigTicketsForOpenPullRequestsAndDropsClosedOnes(): void
|
||||||
|
{
|
||||||
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
|
$repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]);
|
||||||
|
$issues = [self::MONITOR_SLUG => ['open' => [], 'closed' => []]];
|
||||||
|
|
||||||
|
(new CodebergSyncService(
|
||||||
|
new FakeCodebergClient($issues, [self::MONITOR_SLUG => 2]), $tickets, $repoConfigs, self::kpis()
|
||||||
|
))->sync();
|
||||||
|
|
||||||
|
$pr = $tickets->findByExternalRef('101', self::MONITOR_SLUG);
|
||||||
|
self::assertSame('fertig', $pr['column_name']);
|
||||||
|
self::assertSame(1, (int) $pr['is_pull_request']);
|
||||||
|
self::assertSame('#fbca04', $pr['color']);
|
||||||
|
self::assertNotNull($tickets->findByExternalRef('102', self::MONITOR_SLUG));
|
||||||
|
|
||||||
|
(new CodebergSyncService(
|
||||||
|
new FakeCodebergClient($issues, [self::MONITOR_SLUG => 1]), $tickets, $repoConfigs, self::kpis()
|
||||||
|
))->sync();
|
||||||
|
|
||||||
|
self::assertNotNull($tickets->findByExternalRef('101', self::MONITOR_SLUG));
|
||||||
|
self::assertNull($tickets->findByExternalRef('102', self::MONITOR_SLUG));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResyncKeepsOpenPullRequestTickets(): void
|
||||||
|
{
|
||||||
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
|
$client = new FakeCodebergClient(
|
||||||
|
[self::MONITOR_SLUG => ['open' => [], 'closed' => []]],
|
||||||
|
[self::MONITOR_SLUG => 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
(new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]), self::kpis()))->resync();
|
||||||
|
|
||||||
|
self::assertNotNull($tickets->findByExternalRef('101', self::MONITOR_SLUG));
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array{string, SyncTrigger} */
|
/** @return array{string, SyncTrigger} */
|
||||||
private static function deployTrigger(): array
|
private static function deployTrigger(): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -186,23 +186,29 @@ namespace Monitor\Api\Tests\Sync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCountOpenPullRequestsCountsSinglePage(): void
|
public function testListOpenPullRequestsReturnsNumberTitleAndBody(): void
|
||||||
{
|
{
|
||||||
CurlStub::$queue = [
|
CurlStub::$queue = [
|
||||||
['status' => 200, 'body' => json_encode([
|
['status' => 200, 'body' => json_encode([
|
||||||
['number' => 1, 'title' => 'PR A'],
|
['number' => 1, 'title' => 'PR A', 'body' => 'Text'],
|
||||||
['number' => 2, 'title' => 'PR B'],
|
['number' => 2, 'title' => 'PR B'],
|
||||||
])],
|
])],
|
||||||
];
|
];
|
||||||
|
|
||||||
$count = (new CurlCodebergClient('https://codeberg.org/api/v1'))->countOpenPullRequests('fds2610', 'monitor');
|
$pulls = (new CurlCodebergClient('https://codeberg.org/api/v1'))->listOpenPullRequests('fds2610', 'monitor');
|
||||||
|
|
||||||
self::assertSame(2, $count);
|
self::assertSame(
|
||||||
|
[
|
||||||
|
['number' => 1, 'title' => 'PR A', 'body' => 'Text'],
|
||||||
|
['number' => 2, 'title' => 'PR B', 'body' => null],
|
||||||
|
],
|
||||||
|
$pulls
|
||||||
|
);
|
||||||
self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]);
|
self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]);
|
||||||
self::assertStringContainsString('type=pulls', CurlStub::$requestedUrls[0]);
|
self::assertStringContainsString('type=pulls', CurlStub::$requestedUrls[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCountOpenPullRequestsSumsAcrossPages(): void
|
public function testListOpenPullRequestsFollowsPages(): void
|
||||||
{
|
{
|
||||||
$fullPage = array_fill(0, 50, ['number' => 1, 'title' => 'PR']);
|
$fullPage = array_fill(0, 50, ['number' => 1, 'title' => 'PR']);
|
||||||
CurlStub::$queue = [
|
CurlStub::$queue = [
|
||||||
|
|
@ -210,9 +216,9 @@ namespace Monitor\Api\Tests\Sync {
|
||||||
['status' => 200, 'body' => json_encode([['number' => 99, 'title' => 'Letzte PR']])],
|
['status' => 200, 'body' => json_encode([['number' => 99, 'title' => 'Letzte PR']])],
|
||||||
];
|
];
|
||||||
|
|
||||||
$count = (new CurlCodebergClient('https://codeberg.org/api/v1'))->countOpenPullRequests('fds2610', 'monitor');
|
$pulls = (new CurlCodebergClient('https://codeberg.org/api/v1'))->listOpenPullRequests('fds2610', 'monitor');
|
||||||
|
|
||||||
self::assertSame(51, $count);
|
self::assertCount(51, $pulls);
|
||||||
self::assertCount(2, CurlStub::$requestedUrls);
|
self::assertCount(2, CurlStub::$requestedUrls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,9 @@ final class RepoHealthCheckerTest extends TestCase
|
||||||
return $this->listWebhooks ?? [];
|
return $this->listWebhooks ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countOpenPullRequests(string $owner, string $repo): int
|
public function listOpenPullRequests(string $owner, string $repo): array
|
||||||
{
|
{
|
||||||
return 0;
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,12 @@ header.office-header h1 {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-pr-stripes {
|
||||||
|
fill: url(#pr-stripes);
|
||||||
|
stroke: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.note-stack-badge {
|
.note-stack-badge {
|
||||||
fill: var(--color-note-stack-badge);
|
fill: var(--color-note-stack-badge);
|
||||||
stroke: #92400e;
|
stroke: #92400e;
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,8 @@ function paintNote(rect, color) {
|
||||||
return rect;
|
return rect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PR_STRIPE_PATTERN_ID = "pr-stripes";
|
||||||
|
|
||||||
function renderStackBadge(x, y, count, stackColors = []) {
|
function renderStackBadge(x, y, count, stackColors = []) {
|
||||||
const g = el("g", { class: "note-stack" });
|
const g = el("g", { class: "note-stack" });
|
||||||
[6, 3, 0].forEach((offset, i) => {
|
[6, 3, 0].forEach((offset, i) => {
|
||||||
|
|
@ -253,6 +255,8 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick)
|
||||||
);
|
);
|
||||||
g.appendChild(renderBoardReloadHint(onReloadClick));
|
g.appendChild(renderBoardReloadHint(onReloadClick));
|
||||||
|
|
||||||
|
g.appendChild(renderPullRequestStripePattern());
|
||||||
|
|
||||||
const columnWidth = BOARD_FRAME.w / columns.length;
|
const columnWidth = BOARD_FRAME.w / columns.length;
|
||||||
|
|
||||||
columns.forEach((column, index) => {
|
columns.forEach((column, index) => {
|
||||||
|
|
@ -300,6 +304,19 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick)
|
||||||
column.noteColors[i]
|
column.noteColors[i]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
// Pull Requests tragen zusaetzlich schwarze Querstreifen ueber der Projektfarbe.
|
||||||
|
if (column.notePullRequests?.[i]) {
|
||||||
|
colGroup.appendChild(
|
||||||
|
el("rect", {
|
||||||
|
x: noteX,
|
||||||
|
y: noteY,
|
||||||
|
width: NOTE_W,
|
||||||
|
height: NOTE_H,
|
||||||
|
rx: 6,
|
||||||
|
class: "note-pr-stripes",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
if (column.noteNumbers?.[i]) {
|
if (column.noteNumbers?.[i]) {
|
||||||
colGroup.appendChild(
|
colGroup.appendChild(
|
||||||
text(column.noteNumbers[i], {
|
text(column.noteNumbers[i], {
|
||||||
|
|
@ -325,6 +342,19 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick)
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPullRequestStripePattern() {
|
||||||
|
const defs = el("defs");
|
||||||
|
const pattern = el("pattern", {
|
||||||
|
id: PR_STRIPE_PATTERN_ID,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
patternUnits: "userSpaceOnUse",
|
||||||
|
});
|
||||||
|
pattern.appendChild(el("rect", { x: 0, y: 0, width: 10, height: 3, fill: "#000000" }));
|
||||||
|
defs.appendChild(pattern);
|
||||||
|
return defs;
|
||||||
|
}
|
||||||
|
|
||||||
// Issue #51: das Reload-Symbol sitzt neben der Board-Ueberschrift, analog zum Zahnrad am
|
// Issue #51: das Reload-Symbol sitzt neben der Board-Ueberschrift, analog zum Zahnrad am
|
||||||
// PO-Platz (renderPoConfigHint) - selbe "aus Grundformen statt Icon-Bibliothek gebaute
|
// PO-Platz (renderPoConfigHint) - selbe "aus Grundformen statt Icon-Bibliothek gebaute
|
||||||
// Silhouette" plus Hover-Hervorhebung, damit beide Hinweise als ein visuelles System wirken.
|
// Silhouette" plus Hover-Hervorhebung, damit beide Hinweise als ein visuelles System wirken.
|
||||||
|
|
@ -1450,7 +1480,7 @@ function renderTicketModalBody(body, content) {
|
||||||
if (item.number) {
|
if (item.number) {
|
||||||
const numEl = item.link ? document.createElement("a") : document.createElement("span");
|
const numEl = item.link ? document.createElement("a") : document.createElement("span");
|
||||||
numEl.className = "office-modal-ticket-number";
|
numEl.className = "office-modal-ticket-number";
|
||||||
numEl.textContent = `#${item.number}`;
|
numEl.textContent = `${item.isPullRequest ? "PR " : ""}#${item.number}`;
|
||||||
if (item.link) {
|
if (item.link) {
|
||||||
numEl.href = item.link;
|
numEl.href = item.link;
|
||||||
numEl.target = "_blank";
|
numEl.target = "_blank";
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ export const NOTE_STACK_LAYERS = 3;
|
||||||
* @param {Record<string, Array<object>>} groupedTickets - Tickets je Spalte in derselben
|
* @param {Record<string, Array<object>>} groupedTickets - Tickets je Spalte in derselben
|
||||||
* Reihenfolge wie gezaehlt (siehe groupTicketsByColumn()).
|
* Reihenfolge wie gezaehlt (siehe groupTicketsByColumn()).
|
||||||
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number,
|
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number,
|
||||||
* stackCount: number, noteColors: Array<string|null>, noteNumbers: Array<string|null>, stackColors: Array<string>}>}
|
* stackCount: number, noteColors: Array<string|null>, noteNumbers: Array<string|null>,
|
||||||
|
* notePullRequests: boolean[], stackColors: Array<string>}>}
|
||||||
*/
|
*/
|
||||||
export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) {
|
export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) {
|
||||||
return BOARD_COLUMNS.map(({ key, label }) => {
|
return BOARD_COLUMNS.map(({ key, label }) => {
|
||||||
|
|
@ -65,6 +66,7 @@ export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) {
|
||||||
stackCount,
|
stackCount,
|
||||||
noteColors: noteColors(tickets.slice(0, visibleNotes), visibleNotes),
|
noteColors: noteColors(tickets.slice(0, visibleNotes), visibleNotes),
|
||||||
noteNumbers: noteNumbers(tickets.slice(0, visibleNotes), visibleNotes),
|
noteNumbers: noteNumbers(tickets.slice(0, visibleNotes), visibleNotes),
|
||||||
|
notePullRequests: Array.from({ length: visibleNotes }, (_, i) => isPullRequest(tickets[i])),
|
||||||
stackColors: stackCount > 0 ? stackLayerColors(tickets.slice(visibleNotes)) : [],
|
stackColors: stackCount > 0 ? stackLayerColors(tickets.slice(visibleNotes)) : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,10 +93,16 @@ function noteColors(tickets, length) {
|
||||||
function noteNumbers(tickets, length) {
|
function noteNumbers(tickets, length) {
|
||||||
return Array.from({ length }, (_, i) => {
|
return Array.from({ length }, (_, i) => {
|
||||||
const ticket = tickets[i];
|
const ticket = tickets[i];
|
||||||
return ticket?.source === "codeberg" && ticket.external_ref ? `#${ticket.external_ref}` : null;
|
if (ticket?.source !== "codeberg" || !ticket.external_ref) return null;
|
||||||
|
return `${isPullRequest(ticket) ? "PR " : ""}#${ticket.external_ref}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offene Pull Requests stehen als Tickets in der Spalte "fertig" (is_pull_request = 1).
|
||||||
|
function isPullRequest(ticket) {
|
||||||
|
return Boolean(ticket?.is_pull_request);
|
||||||
|
}
|
||||||
|
|
||||||
// Ein Stapel steht fuer viele Tickets, aber nur fuer NOTE_STACK_LAYERS Zettel: er zeigt
|
// Ein Stapel steht fuer viele Tickets, aber nur fuer NOTE_STACK_LAYERS Zettel: er zeigt
|
||||||
// deshalb die ersten verschiedenen Projektfarben darin, statt eine davon dreimal zu
|
// deshalb die ersten verschiedenen Projektfarben darin, statt eine davon dreimal zu
|
||||||
// wiederholen. So ist auch an "Done" (immer ein Stapel) zu sehen, aus welchen Projekten die
|
// wiederholen. So ist auch an "Done" (immer ein Stapel) zu sehen, aus welchen Projekten die
|
||||||
|
|
@ -255,9 +263,12 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
|
||||||
? ticket.repo || `${codebergOwner}/${codebergRepo}`
|
? ticket.repo || `${codebergOwner}/${codebergRepo}`
|
||||||
: null,
|
: null,
|
||||||
number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null,
|
number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null,
|
||||||
|
isPullRequest: isPullRequest(ticket),
|
||||||
link:
|
link:
|
||||||
ticket.source === "codeberg" && ticket.external_ref
|
ticket.source === "codeberg" && ticket.external_ref
|
||||||
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/issues/${ticket.external_ref}`
|
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${
|
||||||
|
isPullRequest(ticket) ? "pulls" : "issues"
|
||||||
|
}/${ticket.external_ref}`
|
||||||
: null,
|
: null,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -980,3 +980,23 @@ test("buildTicketModalContent kuerzt Beschreibungen auf 10 Zeilen mit '...' (Iss
|
||||||
assert.equal(long.description, `${lines.slice(0, 10).join("\n")}\n...`);
|
assert.equal(long.description, `${lines.slice(0, 10).join("\n")}\n...`);
|
||||||
assert.equal(exact.description, lines.slice(0, 10).join("\n"));
|
assert.equal(exact.description, lines.slice(0, 10).join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Pull-Request-Tickets bekommen 'PR #n' als Nummer, Streifen-Markierung und einen /pulls/-Link", () => {
|
||||||
|
const tickets = [
|
||||||
|
{ id: 1, column_name: "fertig", source: "codeberg", repo: "fds2610/monitor", external_ref: "7", title: "PR", is_pull_request: 1 },
|
||||||
|
{ id: 2, column_name: "fertig", source: "codeberg", repo: "fds2610/monitor", external_ref: "8", title: "Issue", is_pull_request: 0 },
|
||||||
|
];
|
||||||
|
const fertig = buildBoardColumnLayout(
|
||||||
|
aggregateTicketsByColumn(tickets),
|
||||||
|
groupTicketsByColumn(tickets)
|
||||||
|
).find((c) => c.key === "fertig");
|
||||||
|
|
||||||
|
assert.deepEqual(fertig.noteNumbers, ["PR #7", "#8"]);
|
||||||
|
assert.deepEqual(fertig.notePullRequests, [true, false]);
|
||||||
|
|
||||||
|
const items = buildTicketModalContent("fertig", tickets).items;
|
||||||
|
const byNumber = Object.fromEntries(items.map((i) => [i.number, i]));
|
||||||
|
assert.equal(byNumber["7"].link, "https://codeberg.org/fds2610/monitor/pulls/7");
|
||||||
|
assert.equal(byNumber["7"].isPullRequest, true);
|
||||||
|
assert.equal(byNumber["8"].link, "https://codeberg.org/fds2610/monitor/issues/8");
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue