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:
fds2610-bot 2026-10-01 11:27:27 +02:00
parent 9c87840253
commit f14d851410
15 changed files with 231 additions and 40 deletions

2
API.md
View file

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

View 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;

View file

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

View file

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

View file

@ -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
{ {

View file

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

View file

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

View file

@ -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 [];
} }
} }

View file

@ -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
{ {

View file

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

View file

@ -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 [];
} }
}; };
} }

View file

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

View file

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

View file

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

View file

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