From 1b6873db627d8e2feb11f9652b069645bafbb361 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 10 Oct 2026 09:04:56 +0200 Subject: [PATCH] Rote Fahne am PO fuer offene Tickets externer User (#113) Der Codeberg-Sync speichert den Ersteller (user.login) von Issues und PRs in der neuen Ticket-Spalte author (Migration 010). Die Buero-Visualisierung zaehlt offene Tickets (alles ausser Done), deren Ersteller weder fds2610 noch fds2610-bot ist, und zeigt die Anzahl in einer kleinen roten Fahne auf dem PO-Tisch. Co-Authored-By: Claude Opus 5.5 --- API.md | 5 ++- CHANGELOG.md | 3 ++ README.md | 3 ++ api/src/Db/migrations/010_ticket_author.sql | 4 ++ api/src/Repository/TicketRepository.php | 30 ++++++++++++-- api/src/Sync/CodebergClient.php | 7 ++-- api/src/Sync/CodebergSyncService.php | 2 + api/src/Sync/CurlCodebergClient.php | 14 +++++++ .../Unit/Sync/CodebergSyncServiceTest.php | 40 ++++++++++++++++++- .../Unit/Sync/CurlCodebergClientTest.php | 29 ++++++++++++-- html/css/office.css | 20 ++++++++++ html/js/office-render.js | 40 ++++++++++++++++++- html/js/office-scene.js | 22 ++++++++++ tests/frontend/office-scene.test.js | 18 +++++++++ 14 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 api/src/Db/migrations/010_ticket_author.sql diff --git a/API.md b/API.md index 459e38f..532ced6 100644 --- a/API.md +++ b/API.md @@ -305,7 +305,10 @@ 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", "is_pull_request", "created_at", "updated_at" }, ... ] }` +Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "is_pull_request", "author", "created_at", "updated_at" }, ... ] }` + +`author` ist der Codeberg-Login, der Issue bzw. PR angelegt hat (vom Codeberg-Sync gepflegt, +bei per API angelegten Tickets `null`, Issue #113). ### `POST /api/v1/tickets` — Ticket anlegen diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfe24b..54fb1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Format in Anlehnung an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## Unreleased +- Rote Fahne am PO mit der Anzahl offener Tickets externer User (Ersteller weder `fds2610` + noch `fds2610-bot`); der Codeberg-Sync speichert dafuer den Ersteller jedes Issues/PRs in + der neuen Ticket-Spalte `author` (#113). - Reload-Countdown im Header der Buero-Visualisierung laedt die Seite bei 0 tatsaechlich neu, statt nur kosmetisch herunterzuzaehlen - ein dauerhaft offener Wand-Tab bekommt dadurch laengstens nach einem `MONITOR_SYNC_INTERVAL` den aktuell deployten Frontend-Stand diff --git a/README.md b/README.md index 049691e..8faa4f3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und (Owner/Repo + Farbe) hinzufügen, bearbeiten und löschen lassen (Issue #27) — Änderungen wirken sofort im laufenden `sync`-Dienst, ohne Neustart. Schreibende Aktionen fragen einmalig den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser. +- Liegen offene Tickets (Issues oder PRs, alles außer „Done") vor, die nicht von `fds2610` + oder `fds2610-bot` angelegt wurden, steht auf dem Tisch des PO eine kleine rote Fahne mit + deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`). - Klick auf das Zahnrad neben der Überschrift „Systeme" öffnet die Verwaltung der per Ping überwachten Hosts (Issue #70): Server hinzufügen, umbenennen und entfernen, ohne sich auf den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von diff --git a/api/src/Db/migrations/010_ticket_author.sql b/api/src/Db/migrations/010_ticket_author.sql new file mode 100644 index 0000000..a581397 --- /dev/null +++ b/api/src/Db/migrations/010_ticket_author.sql @@ -0,0 +1,4 @@ +-- Issue #113: Login des Codeberg-Kontos, das Issue bzw. PR angelegt hat. Grundlage fuer die +-- rote Fahne am PO, die offene Tickets externer User (nicht fds2610/fds2610-bot) zaehlt. +-- Tickets ohne Codeberg-Bezug (per API angelegt) behalten NULL. +ALTER TABLE tickets ADD COLUMN author TEXT; diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index 7112153..d025b3e 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, 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)' + 'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, is_pull_request, author, created_at, updated_at) + VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :is_pull_request, :author, :created_at, :updated_at)' ); $stmt->execute([ 'external_ref' => $data['external_ref'] ?? null, @@ -47,6 +47,7 @@ final class TicketRepository 'assignee_agent_id' => $data['assignee_agent_id'] ?? null, 'source' => $data['source'] ?? 'api', 'is_pull_request' => empty($data['is_pull_request']) ? 0 : 1, + 'author' => $data['author'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]); @@ -156,6 +157,8 @@ final class TicketRepository return $this->create($data + ['source' => 'codeberg']); } + $existing = $this->syncAuthor($existing, $data['author'] ?? null); + // Manuell auf eine nicht vom Sync verwaltete Spalte bewegte Tickets (z. B. fertig) // werden vom periodischen Codeberg-Sync nicht zurueckgesetzt. if (!in_array($existing['column_name'], self::SYNC_MANAGED_COLUMNS, true)) { @@ -199,7 +202,28 @@ final class TicketRepository $updated['is_pull_request'] = 1; } - return $updated; + return $this->syncAuthor($updated, $data['author'] ?? null); + } + + /** + * Issue #113: haelt den Ersteller aktuell - bewusst getrennt von update(), damit auch vor + * Einfuehrung der Spalte angelegte Tickets ihn beim naechsten Sync nachgetragen bekommen, + * selbst wenn sie auf einer nicht vom Sync verwalteten Spalte stehen. + * + * @param array $ticket + * @return array + */ + private function syncAuthor(array $ticket, ?string $author): array + { + if ($author === null || ($ticket['author'] ?? null) === $author) { + return $ticket; + } + + $this->db->pdo()->prepare('UPDATE tickets SET author = :author WHERE id = :id') + ->execute(['author' => $author, 'id' => $ticket['id']]); + $ticket['author'] = $author; + + return $ticket; } /** diff --git a/api/src/Sync/CodebergClient.php b/api/src/Sync/CodebergClient.php index 0f66122..60c580a 100644 --- a/api/src/Sync/CodebergClient.php +++ b/api/src/Sync/CodebergClient.php @@ -8,9 +8,10 @@ interface CodebergClient { /** * Liefert Issues eines Repos fuer den angegebenen Zustand ("open" oder "closed"), - * jeweils mit den Feldern: number, title, body, labels (Liste aus name/color). + * jeweils mit den Feldern: number, title, body, author (Login des Erstellers, Issue #113), + * labels (Liste aus name/color). * - * @return array}> + * @return array}> */ public function listIssues(string $owner, string $repo, string $state): array; @@ -37,7 +38,7 @@ interface CodebergClient * Offene Pull Requests eines Projekts. Grundlage fuer den projektuebergreifenden * "Offene PRs"-KPI (Issue #59) und die PR-Tickets in der Spalte "fertig". * - * @return array + * @return array */ public function listOpenPullRequests(string $owner, string $repo): array; } diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 44af1a5..eb1f43b 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -131,6 +131,7 @@ final class CodebergSyncService 'title' => $pull['title'], 'description' => $pull['body'], 'color' => $color, + 'author' => $pull['author'] ?? null, ]); $refs[] = (string) $pull['number']; } @@ -208,6 +209,7 @@ final class CodebergSyncService 'column' => $column, 'type' => $firstLabel['name'] ?? null, 'color' => $color, + 'author' => $issue['author'] ?? null, ]; } } diff --git a/api/src/Sync/CurlCodebergClient.php b/api/src/Sync/CurlCodebergClient.php index dafb984..baee74b 100644 --- a/api/src/Sync/CurlCodebergClient.php +++ b/api/src/Sync/CurlCodebergClient.php @@ -42,6 +42,7 @@ final class CurlCodebergClient implements CodebergClient 'number' => (int) $issue['number'], 'title' => (string) $issue['title'], 'body' => isset($issue['body']) ? (string) $issue['body'] : null, + 'author' => self::authorLogin($issue), 'labels' => array_map( static fn (array $label): array => [ 'name' => (string) $label['name'], @@ -81,6 +82,7 @@ final class CurlCodebergClient implements CodebergClient 'number' => (int) $pull['number'], 'title' => (string) ($pull['title'] ?? ''), 'body' => isset($pull['body']) ? (string) $pull['body'] : null, + 'author' => self::authorLogin($pull), ]; } @@ -92,6 +94,18 @@ final class CurlCodebergClient implements CodebergClient return $pulls; } + /** + * Issue #113: Login des Kontos, das Issue/PR angelegt hat (Feld "user" der Codeberg-API). + * + * @param array $item + */ + private static function authorLogin(array $item): ?string + { + $login = is_array($item['user'] ?? null) ? ($item['user']['login'] ?? null) : null; + + return is_string($login) && $login !== '' ? $login : null; + } + public function repoPermissions(string $owner, string $repo): array { $url = sprintf('%s/repos/%s/%s', $this->apiBase, rawurlencode($owner), rawurlencode($repo)); diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index fa25883..ab6950c 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -22,6 +22,7 @@ final class FakeCodebergClient implements CodebergClient public function __construct( private readonly array $issuesByRepoAndState, private readonly array $openPullRequestsByRepo = [], + private readonly ?string $pullRequestAuthor = null, ) { } @@ -44,7 +45,7 @@ final class FakeCodebergClient implements CodebergClient { $pulls = []; for ($i = 1; $i <= ($this->openPullRequestsByRepo[$owner . '/' . $repo] ?? 0); $i++) { - $pulls[] = ['number' => 100 + $i, 'title' => 'PR ' . $i, 'body' => null]; + $pulls[] = ['number' => 100 + $i, 'title' => 'PR ' . $i, 'body' => null, 'author' => $this->pullRequestAuthor]; } return $pulls; @@ -118,6 +119,43 @@ final class CodebergSyncServiceTest extends TestCase self::assertSame('#fbca04', $done[0]['color']); } + public function testSyncStoresIssueAndPullRequestAuthor(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $client = new FakeCodebergClient( + [self::MONITOR_SLUG => [ + 'open' => [['number' => 1, 'title' => 'Extern', 'body' => null, 'author' => 'jemand', 'labels' => []]], + 'closed' => [['number' => 2, 'title' => 'Ohne Autor', 'body' => null, 'labels' => []]], + ]], + [self::MONITOR_SLUG => 1], + 'fds2610-bot', + ); + + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]), self::kpis()))->sync(); + + self::assertSame('jemand', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['author']); + self::assertNull($tickets->findByExternalRef('2', self::MONITOR_SLUG)['author']); + self::assertSame('fds2610-bot', $tickets->findByExternalRef('101', self::MONITOR_SLUG)['author']); + } + + public function testSyncBackfillsAuthorEvenForTicketsOnManualColumn(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $existing = $tickets->create([ + 'external_ref' => '1', 'repo' => self::MONITOR_SLUG, 'title' => 'Alt', 'column' => 'fertig', 'source' => 'codeberg', + ]); + $client = self::clientForMonitor([ + 'open' => [['number' => 1, 'title' => 'Alt', 'body' => null, 'author' => 'jemand', 'labels' => []]], + 'closed' => [], + ]); + + (new CodebergSyncService($client, $tickets, self::repoConfigsFor([self::MONITOR_REPO]), self::kpis()))->sync(); + + $ticket = $tickets->find((int) $existing['id']); + self::assertSame('fertig', $ticket['column_name']); + self::assertSame('jemand', $ticket['author']); + } + public function testSyncKeepsIssuesFromDifferentProjectsSeparateAndColorsThemPerProject(): void { $tickets = new TicketRepository(Database::inMemory()); diff --git a/api/tests/Unit/Sync/CurlCodebergClientTest.php b/api/tests/Unit/Sync/CurlCodebergClientTest.php index 496daff..1be0e1b 100644 --- a/api/tests/Unit/Sync/CurlCodebergClientTest.php +++ b/api/tests/Unit/Sync/CurlCodebergClientTest.php @@ -88,6 +88,27 @@ namespace Monitor\Api\Tests\Sync { self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]); } + public function testListIssuesAndPullRequestsReadAuthorLogin(): void + { + CurlStub::$queue = [ + ['status' => 200, 'body' => json_encode([ + ['number' => 1, 'title' => 'A', 'body' => null, 'user' => ['login' => 'jemand'], 'labels' => []], + ['number' => 2, 'title' => 'B', 'body' => null, 'labels' => []], + ])], + ['status' => 200, 'body' => json_encode([ + ['number' => 3, 'title' => 'PR', 'body' => null, 'user' => ['login' => 'fds2610-bot']], + ])], + ]; + + $client = new CurlCodebergClient('https://codeberg.org/api/v1'); + $issues = $client->listIssues('fds2610', 'monitor', 'open'); + $pulls = $client->listOpenPullRequests('fds2610', 'monitor'); + + self::assertSame('jemand', $issues[0]['author']); + self::assertNull($issues[1]['author']); + self::assertSame('fds2610-bot', $pulls[0]['author']); + } + public function testListIssuesStopsWhenPageEmpty(): void { CurlStub::$queue = [ @@ -186,11 +207,11 @@ namespace Monitor\Api\Tests\Sync { } } - public function testListOpenPullRequestsReturnsNumberTitleAndBody(): void + public function testListOpenPullRequestsReturnsNumberTitleBodyAndAuthor(): void { CurlStub::$queue = [ ['status' => 200, 'body' => json_encode([ - ['number' => 1, 'title' => 'PR A', 'body' => 'Text'], + ['number' => 1, 'title' => 'PR A', 'body' => 'Text', 'author' => null], ['number' => 2, 'title' => 'PR B'], ])], ]; @@ -199,8 +220,8 @@ namespace Monitor\Api\Tests\Sync { self::assertSame( [ - ['number' => 1, 'title' => 'PR A', 'body' => 'Text'], - ['number' => 2, 'title' => 'PR B', 'body' => null], + ['number' => 1, 'title' => 'PR A', 'body' => 'Text', 'author' => null], + ['number' => 2, 'title' => 'PR B', 'body' => null, 'author' => null], ], $pulls ); diff --git a/html/css/office.css b/html/css/office.css index fa3b3c7..3c0826a 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -362,6 +362,26 @@ header.office-header h1 { font-weight: 700; } +/* Offene Tickets externer User (Issue #113), rote Fahne auf dem PO-Tisch. */ +.external-ticket-flag-pole { + stroke: #4b5563; + stroke-width: 2; + stroke-linecap: round; +} + +.external-ticket-flag-cloth { + fill: var(--color-recording-badge); + stroke: #7f1d1d; + stroke-width: 1.5; + stroke-linejoin: round; +} + +.external-ticket-flag-count { + fill: #ffffff; + font-size: 13px; + font-weight: 700; +} + .person-head { fill: #f3d9b1; stroke: #d8b98a; diff --git a/html/js/office-render.js b/html/js/office-render.js index ada1866..5439949 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -18,6 +18,7 @@ import { buildEventLines, aggregateTicketsByColumn, groupTicketsByColumn, + countExternalOpenTickets, buildTicketModalContent, mapEventForDisplay, formatEventLineText, @@ -789,14 +790,18 @@ function renderDesk({ deskIndex, agent }) { * @param {object|null} poAgent - {name: "PO", role: "product_owner", pose}, immer gesetzt (der * PO gilt unabhaengig von der API-Erreichbarkeit als anwesend, Issue #19/#97). * @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27). + * @param {number} externalTicketCount - offene Tickets externer User (Issue #113). */ -function renderDesks(desks, poAgent, onPoClick) { +function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) { const g = el("g", { class: "desks" }); desks.forEach((desk) => g.appendChild(renderDesk(desk))); const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w); poDeskGroup.classList.add("po-desk", "clickable"); poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten"); poDeskGroup.appendChild(renderPoConfigHint()); + if (externalTicketCount > 0) { + poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount)); + } // Eine selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte, // Stuhl und Namensschild liegen Luecken, in denen ein Klick "auf den PO" beim Boden landete // (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog @@ -833,6 +838,32 @@ function renderPoConfigHint() { return g; } +// Issue #113: kleine rote Fahne auf dem PO-Tisch, sobald offene Tickets von externen Usern +// (nicht fds2610/fds2610-bot) vorliegen - die Zahl steht im Fahnentuch. Sitzt zwischen +// Bildschirm (x 20-80) und Blumen-Deko (ab x ~122), damit sie mit keinem von beiden kollidiert. +function renderExternalTicketFlag(count) { + const poleX = 92; + const g = el("g", { class: "external-ticket-flag" }); + + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = `${count} offene${count === 1 ? "s" : ""} Ticket${count === 1 ? "" : "s"} externer User`; + g.appendChild(title); + g.appendChild(el("line", { x1: poleX, y1: 4, x2: poleX, y2: 48, class: "external-ticket-flag-pole" })); + g.appendChild( + el("path", { d: `M${poleX},4 h28 l-6,10 l6,10 h-28 z`, class: "external-ticket-flag-cloth" }) + ); + g.appendChild( + text(String(count), { + x: poleX + 12, + y: 19, + class: "external-ticket-flag-count", + "text-anchor": "middle", + }) + ); + + return g; +} + /** * Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad nicht * zu unterscheiden und ohne fremde Icon-Bibliothek zu haben. Gemeinsame Silhouette fuer alle @@ -2425,7 +2456,12 @@ export function renderOfficeScene(container, data = {}) { // weiterlief. Auch ohne erreichbare API bleibt der Platz zudem klickbar: das Modal sagt dann // selbst, dass es die Projekte nicht laden konnte (Fehlerbericht zu Issue #27). const poDeskAgent = { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" }; - const desksGroup = renderDesks(deskLayout, poDeskAgent, openRepoConfigModal); + const desksGroup = renderDesks( + deskLayout, + poDeskAgent, + openRepoConfigModal, + countExternalOpenTickets(tickets) + ); const existingDesks = svg.querySelector(".desks"); if (existingDesks) { existingDesks.replaceWith(desksGroup); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 703bbc0..c1792ae 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -212,6 +212,28 @@ export function groupTicketsByColumn(tickets = []) { return groups; } +// Issue #113: Tickets dieser Codeberg-Konten gelten als intern (Besitzer und Bot); alles andere +// stammt von externen Usern und wird am PO als rote Fahne gezaehlt. +export const INTERNAL_TICKET_AUTHORS = ["fds2610", "fds2610-bot"]; + +/** + * Zaehlt offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne + * author (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit. + * + * @param {Array<{column_name: string, author?: string|null}>} tickets + * @returns {number} + */ +export function countExternalOpenTickets(tickets = []) { + const internal = INTERNAL_TICKET_AUTHORS.map((login) => login.toLowerCase()); + return tickets.filter( + (ticket) => + ticket.column_name !== "done" && + typeof ticket.author === "string" && + ticket.author !== "" && + !internal.includes(ticket.author.toLowerCase()) + ).length; +} + // Issue #88: Beschreibungen im Ticket-Modal werden auf diese Zeilenzahl gekuerzt. export const TICKET_DESCRIPTION_MAX_LINES = 10; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index c985636..cd7bed7 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -56,6 +56,8 @@ import { DEFAULT_RELOAD_INTERVAL_SECONDS, resolveReloadIntervalSeconds, nextCountdownSeconds, + countExternalOpenTickets, + INTERNAL_TICKET_AUTHORS, } from "../../html/js/office-scene.js"; test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => { @@ -988,3 +990,19 @@ test("Pull-Request-Tickets bekommen 'PR #n' als Nummer, Streifen-Markierung und assert.equal(byNumber["7"].isPullRequest, true); assert.equal(byNumber["8"].link, "https://codeberg.org/fds2610/monitor/issues/8"); }); + +test("countExternalOpenTickets zaehlt nur offene Tickets externer User (Issue #113)", () => { + const tickets = [ + { column_name: "backlog", author: "jemand" }, + { column_name: "fertig", author: "extern-pr", is_pull_request: 1 }, + { column_name: "in_arbeit", author: "fds2610" }, + { column_name: "backlog", author: "FDS2610-Bot" }, + { column_name: "done", author: "jemand" }, + { column_name: "backlog", author: null }, + { column_name: "backlog" }, + ]; + assert.equal(countExternalOpenTickets(tickets), 2); + assert.equal(countExternalOpenTickets([]), 0); + assert.equal(countExternalOpenTickets(), 0); + assert.deepEqual(INTERNAL_TICKET_AUTHORS, ["fds2610", "fds2610-bot"]); +});