Merge pull request 'Rote Fahne am PO für offene Tickets externer User' (#114) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/114
This commit is contained in:
fds2610 2026-10-10 09:32:15 +02:00
commit 41a11a0a73
14 changed files with 223 additions and 14 deletions

5
API.md
View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, mixed> $ticket
* @return array<string, mixed>
*/
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;
}
/**

View file

@ -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<int, array{number: int, title: string, body: ?string, labels: array<int, array{name: string, color: string}>}>
* @return array<int, array{number: int, title: string, body: ?string, author?: ?string, labels: array<int, array{name: string, color: string}>}>
*/
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<int, array{number: int, title: string, body: ?string}>
* @return array<int, array{number: int, title: string, body: ?string, author?: ?string}>
*/
public function listOpenPullRequests(string $owner, string $repo): array;
}

View file

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

View file

@ -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<string, mixed> $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));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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