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:
commit
41a11a0a73
14 changed files with 223 additions and 14 deletions
5
API.md
5
API.md
|
|
@ -305,7 +305,10 @@ 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", "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
|
### `POST /api/v1/tickets` — Ticket anlegen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ Format in Anlehnung an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||||
|
|
||||||
## Unreleased
|
## 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,
|
- 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
|
statt nur kosmetisch herunterzuzaehlen - ein dauerhaft offener Wand-Tab bekommt dadurch
|
||||||
laengstens nach einem `MONITOR_SYNC_INTERVAL` den aktuell deployten Frontend-Stand
|
laengstens nach einem `MONITOR_SYNC_INTERVAL` den aktuell deployten Frontend-Stand
|
||||||
|
|
|
||||||
|
|
@ -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
|
(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
|
wirken sofort im laufenden `sync`-Dienst, ohne Neustart. Schreibende Aktionen fragen einmalig
|
||||||
den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser.
|
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
|
- 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
|
ü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
|
den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von
|
||||||
|
|
|
||||||
4
api/src/Db/migrations/010_ticket_author.sql
Normal file
4
api/src/Db/migrations/010_ticket_author.sql
Normal 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;
|
||||||
|
|
@ -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, 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, :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([
|
$stmt->execute([
|
||||||
'external_ref' => $data['external_ref'] ?? null,
|
'external_ref' => $data['external_ref'] ?? null,
|
||||||
|
|
@ -47,6 +47,7 @@ final class TicketRepository
|
||||||
'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,
|
'is_pull_request' => empty($data['is_pull_request']) ? 0 : 1,
|
||||||
|
'author' => $data['author'] ?? null,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
@ -156,6 +157,8 @@ final class TicketRepository
|
||||||
return $this->create($data + ['source' => 'codeberg']);
|
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)
|
// Manuell auf eine nicht vom Sync verwaltete Spalte bewegte Tickets (z. B. fertig)
|
||||||
// werden vom periodischen Codeberg-Sync nicht zurueckgesetzt.
|
// werden vom periodischen Codeberg-Sync nicht zurueckgesetzt.
|
||||||
if (!in_array($existing['column_name'], self::SYNC_MANAGED_COLUMNS, true)) {
|
if (!in_array($existing['column_name'], self::SYNC_MANAGED_COLUMNS, true)) {
|
||||||
|
|
@ -199,7 +202,28 @@ final class TicketRepository
|
||||||
$updated['is_pull_request'] = 1;
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ interface CodebergClient
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Liefert Issues eines Repos fuer den angegebenen Zustand ("open" oder "closed"),
|
* 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;
|
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 Pull Requests eines Projekts. Grundlage fuer den projektuebergreifenden
|
||||||
* "Offene PRs"-KPI (Issue #59) und die PR-Tickets in der Spalte "fertig".
|
* "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;
|
public function listOpenPullRequests(string $owner, string $repo): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ final class CodebergSyncService
|
||||||
'title' => $pull['title'],
|
'title' => $pull['title'],
|
||||||
'description' => $pull['body'],
|
'description' => $pull['body'],
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
|
'author' => $pull['author'] ?? null,
|
||||||
]);
|
]);
|
||||||
$refs[] = (string) $pull['number'];
|
$refs[] = (string) $pull['number'];
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +209,7 @@ final class CodebergSyncService
|
||||||
'column' => $column,
|
'column' => $column,
|
||||||
'type' => $firstLabel['name'] ?? null,
|
'type' => $firstLabel['name'] ?? null,
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
|
'author' => $issue['author'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ final class CurlCodebergClient implements CodebergClient
|
||||||
'number' => (int) $issue['number'],
|
'number' => (int) $issue['number'],
|
||||||
'title' => (string) $issue['title'],
|
'title' => (string) $issue['title'],
|
||||||
'body' => isset($issue['body']) ? (string) $issue['body'] : null,
|
'body' => isset($issue['body']) ? (string) $issue['body'] : null,
|
||||||
|
'author' => self::authorLogin($issue),
|
||||||
'labels' => array_map(
|
'labels' => array_map(
|
||||||
static fn (array $label): array => [
|
static fn (array $label): array => [
|
||||||
'name' => (string) $label['name'],
|
'name' => (string) $label['name'],
|
||||||
|
|
@ -81,6 +82,7 @@ final class CurlCodebergClient implements CodebergClient
|
||||||
'number' => (int) $pull['number'],
|
'number' => (int) $pull['number'],
|
||||||
'title' => (string) ($pull['title'] ?? ''),
|
'title' => (string) ($pull['title'] ?? ''),
|
||||||
'body' => isset($pull['body']) ? (string) $pull['body'] : null,
|
'body' => isset($pull['body']) ? (string) $pull['body'] : null,
|
||||||
|
'author' => self::authorLogin($pull),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +94,18 @@ final class CurlCodebergClient implements CodebergClient
|
||||||
return $pulls;
|
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
|
public function repoPermissions(string $owner, string $repo): array
|
||||||
{
|
{
|
||||||
$url = sprintf('%s/repos/%s/%s', $this->apiBase, rawurlencode($owner), rawurlencode($repo));
|
$url = sprintf('%s/repos/%s/%s', $this->apiBase, rawurlencode($owner), rawurlencode($repo));
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ final class FakeCodebergClient implements CodebergClient
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $issuesByRepoAndState,
|
private readonly array $issuesByRepoAndState,
|
||||||
private readonly array $openPullRequestsByRepo = [],
|
private readonly array $openPullRequestsByRepo = [],
|
||||||
|
private readonly ?string $pullRequestAuthor = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +45,7 @@ final class FakeCodebergClient implements CodebergClient
|
||||||
{
|
{
|
||||||
$pulls = [];
|
$pulls = [];
|
||||||
for ($i = 1; $i <= ($this->openPullRequestsByRepo[$owner . '/' . $repo] ?? 0); $i++) {
|
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;
|
return $pulls;
|
||||||
|
|
@ -118,6 +119,43 @@ final class CodebergSyncServiceTest extends TestCase
|
||||||
self::assertSame('#fbca04', $done[0]['color']);
|
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
|
public function testSyncKeepsIssuesFromDifferentProjectsSeparateAndColorsThemPerProject(): void
|
||||||
{
|
{
|
||||||
$tickets = new TicketRepository(Database::inMemory());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,27 @@ namespace Monitor\Api\Tests\Sync {
|
||||||
self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]);
|
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
|
public function testListIssuesStopsWhenPageEmpty(): void
|
||||||
{
|
{
|
||||||
CurlStub::$queue = [
|
CurlStub::$queue = [
|
||||||
|
|
@ -186,11 +207,11 @@ namespace Monitor\Api\Tests\Sync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testListOpenPullRequestsReturnsNumberTitleAndBody(): void
|
public function testListOpenPullRequestsReturnsNumberTitleBodyAndAuthor(): void
|
||||||
{
|
{
|
||||||
CurlStub::$queue = [
|
CurlStub::$queue = [
|
||||||
['status' => 200, 'body' => json_encode([
|
['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'],
|
['number' => 2, 'title' => 'PR B'],
|
||||||
])],
|
])],
|
||||||
];
|
];
|
||||||
|
|
@ -199,8 +220,8 @@ namespace Monitor\Api\Tests\Sync {
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[
|
[
|
||||||
['number' => 1, 'title' => 'PR A', 'body' => 'Text'],
|
['number' => 1, 'title' => 'PR A', 'body' => 'Text', 'author' => null],
|
||||||
['number' => 2, 'title' => 'PR B', 'body' => null],
|
['number' => 2, 'title' => 'PR B', 'body' => null, 'author' => null],
|
||||||
],
|
],
|
||||||
$pulls
|
$pulls
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,26 @@ header.office-header h1 {
|
||||||
font-weight: 700;
|
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 {
|
.person-head {
|
||||||
fill: #f3d9b1;
|
fill: #f3d9b1;
|
||||||
stroke: #d8b98a;
|
stroke: #d8b98a;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
buildEventLines,
|
buildEventLines,
|
||||||
aggregateTicketsByColumn,
|
aggregateTicketsByColumn,
|
||||||
groupTicketsByColumn,
|
groupTicketsByColumn,
|
||||||
|
countExternalOpenTickets,
|
||||||
buildTicketModalContent,
|
buildTicketModalContent,
|
||||||
mapEventForDisplay,
|
mapEventForDisplay,
|
||||||
formatEventLineText,
|
formatEventLineText,
|
||||||
|
|
@ -789,14 +790,18 @@ function renderDesk({ deskIndex, agent }) {
|
||||||
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose}, immer gesetzt (der
|
* @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).
|
* PO gilt unabhaengig von der API-Erreichbarkeit als anwesend, Issue #19/#97).
|
||||||
* @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27).
|
* @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" });
|
const g = el("g", { class: "desks" });
|
||||||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||||
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
||||||
poDeskGroup.classList.add("po-desk", "clickable");
|
poDeskGroup.classList.add("po-desk", "clickable");
|
||||||
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
|
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
|
||||||
poDeskGroup.appendChild(renderPoConfigHint());
|
poDeskGroup.appendChild(renderPoConfigHint());
|
||||||
|
if (externalTicketCount > 0) {
|
||||||
|
poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount));
|
||||||
|
}
|
||||||
// Eine <g> selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte,
|
// 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
|
// 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
|
// (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog
|
||||||
|
|
@ -833,6 +838,32 @@ function renderPoConfigHint() {
|
||||||
return g;
|
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
|
* 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
|
* 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
|
// 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).
|
// selbst, dass es die Projekte nicht laden konnte (Fehlerbericht zu Issue #27).
|
||||||
const poDeskAgent = { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" };
|
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");
|
const existingDesks = svg.querySelector(".desks");
|
||||||
if (existingDesks) {
|
if (existingDesks) {
|
||||||
existingDesks.replaceWith(desksGroup);
|
existingDesks.replaceWith(desksGroup);
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,28 @@ export function groupTicketsByColumn(tickets = []) {
|
||||||
return groups;
|
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.
|
// Issue #88: Beschreibungen im Ticket-Modal werden auf diese Zeilenzahl gekuerzt.
|
||||||
export const TICKET_DESCRIPTION_MAX_LINES = 10;
|
export const TICKET_DESCRIPTION_MAX_LINES = 10;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ import {
|
||||||
DEFAULT_RELOAD_INTERVAL_SECONDS,
|
DEFAULT_RELOAD_INTERVAL_SECONDS,
|
||||||
resolveReloadIntervalSeconds,
|
resolveReloadIntervalSeconds,
|
||||||
nextCountdownSeconds,
|
nextCountdownSeconds,
|
||||||
|
countExternalOpenTickets,
|
||||||
|
INTERNAL_TICKET_AUTHORS,
|
||||||
} from "../../html/js/office-scene.js";
|
} from "../../html/js/office-scene.js";
|
||||||
|
|
||||||
test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => {
|
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["7"].isPullRequest, true);
|
||||||
assert.equal(byNumber["8"].link, "https://codeberg.org/fds2610/monitor/issues/8");
|
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"]);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue