diff --git a/API.md b/API.md index 268df6c..e535b34 100644 --- a/API.md +++ b/API.md @@ -301,7 +301,7 @@ Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done 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 diff --git a/api/src/Db/migrations/009_ticket_pull_requests.sql b/api/src/Db/migrations/009_ticket_pull_requests.sql new file mode 100644 index 0000000..fd4a982 --- /dev/null +++ b/api/src/Db/migrations/009_ticket_pull_requests.sql @@ -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; diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index d57babb..7084c47 100644 --- a/api/src/Repository/TicketRepository.php +++ b/api/src/Repository/TicketRepository.php @@ -33,8 +33,8 @@ final class TicketRepository $now = $this->db->now(); $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) - VALUES (: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, :is_pull_request, :created_at, :updated_at)' ); $stmt->execute([ 'external_ref' => $data['external_ref'] ?? null, @@ -46,6 +46,7 @@ final class TicketRepository 'color' => $data['color'] ?? null, 'assignee_agent_id' => $data['assignee_agent_id'] ?? null, 'source' => $data['source'] ?? 'api', + 'is_pull_request' => empty($data['is_pull_request']) ? 0 : 1, 'created_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 $data + * @return array + */ + 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 * (Issue #34: nach Entfernen/Umbenennen eines Projekts im Projekte-Modal blieben dessen diff --git a/api/src/Sync/CodebergClient.php b/api/src/Sync/CodebergClient.php index a179911..0f66122 100644 --- a/api/src/Sync/CodebergClient.php +++ b/api/src/Sync/CodebergClient.php @@ -34,8 +34,10 @@ interface CodebergClient public function listWebhooks(string $owner, string $repo): array; /** - * Issue #59: Anzahl offener Pull Requests eines Projekts, Grundlage fuer den - * projektuebergreifenden "Offene PRs"-KPI (CodebergSyncService). + * Offene Pull Requests eines Projekts. Grundlage fuer den projektuebergreifenden + * "Offene PRs"-KPI (Issue #59) und die PR-Tickets in der Spalte "fertig". + * + * @return array */ - public function countOpenPullRequests(string $owner, string $repo): int; + public function listOpenPullRequests(string $owner, string $repo): array; } diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 0950004..44af1a5 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -47,9 +47,13 @@ final class CodebergSyncService public function sync(): void { $repoConfigs = $this->repoConfigs->all(); + $openPrCount = 0; foreach ($repoConfigs as $repoConfig) { $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 @@ -57,7 +61,7 @@ final class CodebergSyncService // zutreffenden Projektfarbe angezeigt. $this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs)); - $this->reportOpenPullRequestKpi($repoConfigs); + $this->reportOpenPullRequestKpi($openPrCount); } /** @@ -74,15 +78,19 @@ final class CodebergSyncService { $repoConfigs = $this->repoConfigs->all(); + $openPrCount = 0; + foreach ($repoConfigs as $repoConfig) { $slug = $repoConfig['owner'] . '/' . $repoConfig['repo']; $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->reportOpenPullRequestKpi($repoConfigs); + $this->reportOpenPullRequestKpi($openPrCount); } /** @@ -90,16 +98,9 @@ final class CodebergSyncService * 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 + 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; $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. */ private function syncRepo(string $owner, string $repo, string $color): array { diff --git a/api/src/Sync/CurlCodebergClient.php b/api/src/Sync/CurlCodebergClient.php index bd5672a..dafb984 100644 --- a/api/src/Sync/CurlCodebergClient.php +++ b/api/src/Sync/CurlCodebergClient.php @@ -60,9 +60,9 @@ final class CurlCodebergClient implements CodebergClient 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++) { $url = sprintf( @@ -75,14 +75,21 @@ final class CurlCodebergClient implements CodebergClient ); $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) { break; } } - return $count; + return $pulls; } public function repoPermissions(string $owner, string $repo): array diff --git a/api/tests/Unit/Controller/RepoConfigControllerTest.php b/api/tests/Unit/Controller/RepoConfigControllerTest.php index 29ebb22..ed98615 100644 --- a/api/tests/Unit/Controller/RepoConfigControllerTest.php +++ b/api/tests/Unit/Controller/RepoConfigControllerTest.php @@ -41,9 +41,9 @@ 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 + public function listOpenPullRequests(string $owner, string $repo): array { - return 0; + return []; } }); $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 ce50822..1ebb155 100644 --- a/api/tests/Unit/Controller/TicketControllerTest.php +++ b/api/tests/Unit/Controller/TicketControllerTest.php @@ -42,9 +42,9 @@ final class StubCodebergClient implements CodebergClient return []; } - public function countOpenPullRequests(string $owner, string $repo): int + public function listOpenPullRequests(string $owner, string $repo): array { - return 0; + return []; } } diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index 960d1d9..fa25883 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -40,9 +40,14 @@ final class FakeCodebergClient implements CodebergClient 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']); } + 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} */ private static function deployTrigger(): array { diff --git a/api/tests/Unit/Sync/CurlCodebergClientTest.php b/api/tests/Unit/Sync/CurlCodebergClientTest.php index 7fdc2b2..496daff 100644 --- a/api/tests/Unit/Sync/CurlCodebergClientTest.php +++ b/api/tests/Unit/Sync/CurlCodebergClientTest.php @@ -186,23 +186,29 @@ namespace Monitor\Api\Tests\Sync { } } - public function testCountOpenPullRequestsCountsSinglePage(): void + public function testListOpenPullRequestsReturnsNumberTitleAndBody(): void { CurlStub::$queue = [ ['status' => 200, 'body' => json_encode([ - ['number' => 1, 'title' => 'PR A'], + ['number' => 1, 'title' => 'PR A', 'body' => 'Text'], ['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('type=pulls', CurlStub::$requestedUrls[0]); } - public function testCountOpenPullRequestsSumsAcrossPages(): void + public function testListOpenPullRequestsFollowsPages(): void { $fullPage = array_fill(0, 50, ['number' => 1, 'title' => 'PR']); CurlStub::$queue = [ @@ -210,9 +216,9 @@ namespace Monitor\Api\Tests\Sync { ['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); } } diff --git a/api/tests/Unit/Sync/RepoHealthCheckerTest.php b/api/tests/Unit/Sync/RepoHealthCheckerTest.php index c0afc7f..20bdda7 100644 --- a/api/tests/Unit/Sync/RepoHealthCheckerTest.php +++ b/api/tests/Unit/Sync/RepoHealthCheckerTest.php @@ -126,9 +126,9 @@ final class RepoHealthCheckerTest extends TestCase return $this->listWebhooks ?? []; } - public function countOpenPullRequests(string $owner, string $repo): int + public function listOpenPullRequests(string $owner, string $repo): array { - return 0; + return []; } }; } diff --git a/html/css/office.css b/html/css/office.css index 02ef5e2..5a03cfb 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -223,6 +223,12 @@ header.office-header h1 { pointer-events: none; } +.note-pr-stripes { + fill: url(#pr-stripes); + stroke: none; + pointer-events: none; +} + .note-stack-badge { fill: var(--color-note-stack-badge); stroke: #92400e; diff --git a/html/js/office-render.js b/html/js/office-render.js index a875d6b..014cc70 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -199,6 +199,8 @@ function paintNote(rect, color) { return rect; } +const PR_STRIPE_PATTERN_ID = "pr-stripes"; + function renderStackBadge(x, y, count, stackColors = []) { const g = el("g", { class: "note-stack" }); [6, 3, 0].forEach((offset, i) => { @@ -253,6 +255,8 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) ); g.appendChild(renderBoardReloadHint(onReloadClick)); + g.appendChild(renderPullRequestStripePattern()); + const columnWidth = BOARD_FRAME.w / columns.length; columns.forEach((column, index) => { @@ -300,6 +304,19 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) 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]) { colGroup.appendChild( text(column.noteNumbers[i], { @@ -325,6 +342,19 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) 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 // PO-Platz (renderPoConfigHint) - selbe "aus Grundformen statt Icon-Bibliothek gebaute // Silhouette" plus Hover-Hervorhebung, damit beide Hinweise als ein visuelles System wirken. @@ -1450,7 +1480,7 @@ function renderTicketModalBody(body, content) { if (item.number) { const numEl = item.link ? document.createElement("a") : document.createElement("span"); numEl.className = "office-modal-ticket-number"; - numEl.textContent = `#${item.number}`; + numEl.textContent = `${item.isPullRequest ? "PR " : ""}#${item.number}`; if (item.link) { numEl.href = item.link; numEl.target = "_blank"; diff --git a/html/js/office-scene.js b/html/js/office-scene.js index b2836ef..1b054dd 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -50,7 +50,8 @@ export const NOTE_STACK_LAYERS = 3; * @param {Record>} groupedTickets - Tickets je Spalte in derselben * Reihenfolge wie gezaehlt (siehe groupTicketsByColumn()). * @returns {Array<{key: string, label: string, count: number, visibleNotes: number, - * stackCount: number, noteColors: Array, noteNumbers: Array, stackColors: Array}>} + * stackCount: number, noteColors: Array, noteNumbers: Array, + * notePullRequests: boolean[], stackColors: Array}>} */ export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) { return BOARD_COLUMNS.map(({ key, label }) => { @@ -65,6 +66,7 @@ export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) { stackCount, noteColors: noteColors(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)) : [], }); @@ -91,10 +93,16 @@ function noteColors(tickets, length) { function noteNumbers(tickets, length) { return Array.from({ length }, (_, 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 // 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 @@ -255,9 +263,12 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) { ? ticket.repo || `${codebergOwner}/${codebergRepo}` : null, number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null, + isPullRequest: isPullRequest(ticket), link: 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, })), }; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index cc6eb62..74af8c5 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -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(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"); +});