Merge pull request 'Reload-Knopf am Kanban-Board: Tickets komplett neu einlesen' (#52) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/52
This commit is contained in:
commit
d896f4c6b1
10 changed files with 403 additions and 17 deletions
18
API.md
18
API.md
|
|
@ -46,7 +46,7 @@ Zwei Schluesseltypen, beide statisch (F14):
|
||||||
|
|
||||||
| Header | Verwendet fuer | Herkunft |
|
| Header | Verwendet fuer | Herkunft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten), `POST`/`PATCH`/`DELETE /api/v1/repo-configs` (Projekte des Codeberg-Sync) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` |
|
| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten), `POST`/`PATCH`/`DELETE /api/v1/repo-configs` (Projekte des Codeberg-Sync), `POST /api/v1/tickets/resync` (Board neu einlesen) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` |
|
||||||
| `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben |
|
| `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben |
|
||||||
|
|
||||||
Ausnahme ist der Codeberg-Webhook (`POST /api/v1/webhooks/codeberg`): Codeberg kennt unsere
|
Ausnahme ist der Codeberg-Webhook (`POST /api/v1/webhooks/codeberg`): Codeberg kennt unsere
|
||||||
|
|
@ -216,6 +216,22 @@ Auth: `X-Api-Key`. Erlaubte Felder im Body: `column`, `title`, `description`, `t
|
||||||
Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger
|
Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger
|
||||||
`column`.
|
`column`.
|
||||||
|
|
||||||
|
### `POST /api/v1/tickets/resync` — Board komplett neu einlesen
|
||||||
|
|
||||||
|
Auth: `X-Admin-Key`. Issue #51: manueller "Reload"-Knopf am Kanban-Board im Frontend fuer den
|
||||||
|
Fall, dass Tickets (geloescht, verschoben, o.ae.) vom regulaeren Codeberg-Sync nicht mehr
|
||||||
|
bereinigt werden. Liest synchron alle konfigurierten Codeberg-Projekte neu ein (siehe
|
||||||
|
[Codeberg-Sync](#codeberg-sync-backlogdone)) und entfernt dabei je Projekt zusaetzlich Tickets,
|
||||||
|
deren Issue nicht mehr unter den offenen/geschlossenen Issues auftaucht - selbst wenn sie
|
||||||
|
manuell auf eine nicht vom Sync verwaltete Spalte wie `fertig` bewegt wurden. Tickets ohne
|
||||||
|
Projektbezug (z.B. per API angelegt) bleiben unberuehrt. Anders als der Webhook-Trigger kein
|
||||||
|
asynchroner Anstoss des sync-Containers, sondern derselbe Ablauf direkt im api-Container, damit
|
||||||
|
die Antwortzeit die tatsaechliche Sync-Dauer widerspiegelt.
|
||||||
|
|
||||||
|
Response `200` mit `{ "tickets": [...] }` (neuer Board-Zustand), `502` falls die Codeberg-API
|
||||||
|
nicht erreichbar war (bereits verarbeitete Projekte bleiben dann auf dem neuen Stand, noch
|
||||||
|
nicht erreichte auf dem alten - der naechste periodische Sync-Lauf oder Webhook gleicht das an).
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
`severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5).
|
`severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5).
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ use Monitor\Api\Repository\LegacyStatusFileReader;
|
||||||
use Monitor\Api\Repository\RepoConfigRepository;
|
use Monitor\Api\Repository\RepoConfigRepository;
|
||||||
use Monitor\Api\Repository\SystemRepository;
|
use Monitor\Api\Repository\SystemRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
|
use Monitor\Api\Sync\CodebergSyncService;
|
||||||
|
use Monitor\Api\Sync\CurlCodebergClient;
|
||||||
use Monitor\Api\Sync\SyncTrigger;
|
use Monitor\Api\Sync\SyncTrigger;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
|
@ -61,7 +63,15 @@ final class Application
|
||||||
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
||||||
|
|
||||||
$agentController = new AgentController($agents);
|
$agentController = new AgentController($agents);
|
||||||
$ticketController = new TicketController($tickets);
|
// Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container,
|
||||||
|
// unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php)
|
||||||
|
// - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden.
|
||||||
|
$syncService = new CodebergSyncService(
|
||||||
|
new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
|
||||||
|
$tickets,
|
||||||
|
$repoConfigs,
|
||||||
|
);
|
||||||
|
$ticketController = new TicketController($tickets, $syncService);
|
||||||
$eventController = new EventController($events);
|
$eventController = new EventController($events);
|
||||||
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
||||||
$kpiController = new KpiController($kpis);
|
$kpiController = new KpiController($kpis);
|
||||||
|
|
@ -78,6 +88,10 @@ final class Application
|
||||||
$this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r));
|
$this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r));
|
||||||
$this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent');
|
$this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent');
|
||||||
$this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent');
|
$this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent');
|
||||||
|
// Issue #51: schreibend wie die Projekte-Verwaltung (Admin-Key), da ein manueller
|
||||||
|
// Voll-Reset des Kanban-Boards potenziell teuer ist (mehrere Codeberg-Requests) und
|
||||||
|
// kein normaler Agenten-Vorgang.
|
||||||
|
$this->router->add('POST', '/api/v1/tickets/resync', fn (Request $r, array $p) => $ticketController->resync($r), 'admin');
|
||||||
|
|
||||||
$this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r));
|
$this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r));
|
||||||
$this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent');
|
$this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent');
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,15 @@ use InvalidArgumentException;
|
||||||
use Monitor\Api\Http\Request;
|
use Monitor\Api\Http\Request;
|
||||||
use Monitor\Api\Http\Response;
|
use Monitor\Api\Http\Response;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
|
use Monitor\Api\Sync\CodebergSyncService;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
final class TicketController
|
final class TicketController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly TicketRepository $tickets)
|
public function __construct(
|
||||||
{
|
private readonly TicketRepository $tickets,
|
||||||
|
private readonly CodebergSyncService $syncService,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
|
|
@ -52,4 +56,23 @@ final class TicketController
|
||||||
|
|
||||||
return Response::json(['ticket' => $ticket]);
|
return Response::json(['ticket' => $ticket]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #51: "Board neu einlesen"-Knopf im Frontend - liest alle konfigurierten
|
||||||
|
* Codeberg-Projekte synchron neu ein und bereinigt dabei Tickets, die dort nicht mehr
|
||||||
|
* auftauchen (siehe CodebergSyncService::resync()), statt (wie der Webhook-Trigger) nur
|
||||||
|
* asynchron einen Lauf des sync-Containers anzustossen. Synchron bewusst so gewaehlt, weil
|
||||||
|
* das Frontend die tatsaechliche Laufzeit sichtbar machen soll (PO steht am Board, bis
|
||||||
|
* diese Anfrage antwortet).
|
||||||
|
*/
|
||||||
|
public function resync(Request $request): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->syncService->resync();
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return Response::error('Codeberg-Sync fehlgeschlagen: ' . $e->getMessage(), 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(['tickets' => $this->tickets->all()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,34 @@ final class TicketRepository
|
||||||
$stmt->execute(['repo' => $repo]);
|
$stmt->execute(['repo' => $repo]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Tickets eines Projekts, deren external_ref beim letzten Neueinlesen nicht mehr
|
||||||
|
* gesehen wurde (Issue #51: manueller "Board neu einlesen"-Knopf, CodebergSyncService::
|
||||||
|
* resync()). Anders als der periodische Sync (reines Upsert, ruehrt bei Codeberg
|
||||||
|
* verschwundene Issues nie an, siehe upsertFromCodeberg()) bereinigt dieser Aufruf gezielt
|
||||||
|
* genau solche Karteileichen, selbst wenn sie manuell auf eine nicht vom Sync verwaltete
|
||||||
|
* Spalte (z.B. "fertig") bewegt wurden. Tickets ohne Projektbezug (repo = NULL, z.B. per
|
||||||
|
* API angelegt) bleiben unberuehrt, weil dieser Aufruf nur je konfiguriertem Projekt
|
||||||
|
* erfolgt.
|
||||||
|
*
|
||||||
|
* @param string[] $keepExternalRefs - external_refs, die beim aktuellen Lauf tatsaechlich
|
||||||
|
* bei Codeberg gesehen wurden.
|
||||||
|
*/
|
||||||
|
public function deleteOrphansForRepo(string $repo, array $keepExternalRefs): void
|
||||||
|
{
|
||||||
|
if ($keepExternalRefs === []) {
|
||||||
|
$this->deleteByRepo($repo);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(', ', array_fill(0, count($keepExternalRefs), '?'));
|
||||||
|
$stmt = $this->db->pdo()->prepare(
|
||||||
|
"DELETE FROM tickets WHERE repo = ? AND external_ref NOT IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$repo, ...$keepExternalRefs]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Billiger "Fingerabdruck" (Anzahl + juengster Aenderungszeitpunkt) fuer den SSE-Endpoint
|
* Billiger "Fingerabdruck" (Anzahl + juengster Aenderungszeitpunkt) fuer den SSE-Endpoint
|
||||||
* (Realtime\ChangeFeed, F13) - aendert sich bei jedem Anlegen/Verschieben eines Tickets.
|
* (Realtime\ChangeFeed, F13) - aendert sich bei jedem Anlegen/Verschieben eines Tickets.
|
||||||
|
|
|
||||||
|
|
@ -52,25 +52,63 @@ final class CodebergSyncService
|
||||||
// Issue #34: Tickets aus zwischenzeitlich entfernten oder umbenannten Projekten werden
|
// Issue #34: Tickets aus zwischenzeitlich entfernten oder umbenannten Projekten werden
|
||||||
// oben nicht mehr angefasst und wuerden sonst dauerhaft in der alten, nicht mehr
|
// oben nicht mehr angefasst und wuerden sonst dauerhaft in der alten, nicht mehr
|
||||||
// zutreffenden Projektfarbe angezeigt.
|
// zutreffenden Projektfarbe angezeigt.
|
||||||
$this->tickets->clearColorForReposNotIn(array_map(
|
$this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs));
|
||||||
static fn (array $repoConfig): string => $repoConfig['owner'] . '/' . $repoConfig['repo'],
|
|
||||||
$repoConfigs
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncRepo(string $owner, string $repo, string $color): void
|
/**
|
||||||
|
* Issue #51: manueller "Board neu einlesen"-Knopf im Frontend. Anders als sync() (reines
|
||||||
|
* Upsert, laesst zwischenzeitlich bei Codeberg geloeschte oder anderweitig verschwundene
|
||||||
|
* Issues als Karteileichen im Kanban stehen) entfernt resync() je Projekt zusaetzlich alle
|
||||||
|
* Tickets, deren external_ref beim Lauf nicht (mehr) unter den offenen/geschlossenen
|
||||||
|
* Issues auftauchte - selbst wenn sie zuvor manuell auf eine nicht vom Sync verwaltete
|
||||||
|
* Spalte (z.B. "fertig") bewegt wurden. Tickets ohne Projektbezug (z.B. per API angelegt)
|
||||||
|
* bleiben unberuehrt, ebenso wie Tickets zu Issues, die bei Codeberg weiterhin existieren
|
||||||
|
* (siehe upsertFromCodeberg() - deren manuell gesetzte Spalte bleibt erhalten).
|
||||||
|
*/
|
||||||
|
public function resync(): void
|
||||||
|
{
|
||||||
|
$repoConfigs = $this->repoConfigs->all();
|
||||||
|
|
||||||
|
foreach ($repoConfigs as $repoConfig) {
|
||||||
|
$slug = $repoConfig['owner'] . '/' . $repoConfig['repo'];
|
||||||
|
$seenExternalRefs = $this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']);
|
||||||
|
$this->tickets->deleteOrphansForRepo($slug, $seenExternalRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */
|
||||||
|
private function syncRepo(string $owner, string $repo, string $color): array
|
||||||
{
|
{
|
||||||
$slug = $owner . '/' . $repo;
|
$slug = $owner . '/' . $repo;
|
||||||
|
$seenExternalRefs = [];
|
||||||
|
|
||||||
foreach ($this->client->listIssues($owner, $repo, 'open') as $issue) {
|
foreach ($this->client->listIssues($owner, $repo, 'open') as $issue) {
|
||||||
$this->tickets->upsertFromCodeberg(
|
$this->tickets->upsertFromCodeberg(
|
||||||
$this->mapIssue($issue, $this->columnForOpenIssue($issue), $slug, $color)
|
$this->mapIssue($issue, $this->columnForOpenIssue($issue), $slug, $color)
|
||||||
);
|
);
|
||||||
|
$seenExternalRefs[] = (string) $issue['number'];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) {
|
foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) {
|
||||||
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done', $slug, $color));
|
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done', $slug, $color));
|
||||||
|
$seenExternalRefs[] = (string) $issue['number'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $seenExternalRefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{owner: string, repo: string}> $repoConfigs
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function repoSlugs(array $repoConfigs): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $repoConfig): string => $repoConfig['owner'] . '/' . $repoConfig['repo'],
|
||||||
|
$repoConfigs
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,29 @@ use Monitor\Api\Controller\TicketController;
|
||||||
use Monitor\Api\Db\Database;
|
use Monitor\Api\Db\Database;
|
||||||
use Monitor\Api\Http\NotFoundException;
|
use Monitor\Api\Http\NotFoundException;
|
||||||
use Monitor\Api\Http\Request;
|
use Monitor\Api\Http\Request;
|
||||||
|
use Monitor\Api\Repository\RepoConfigRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
|
use Monitor\Api\Sync\CodebergClient;
|
||||||
|
use Monitor\Api\Sync\CodebergSyncService;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/** Testdouble fuer die Resync-Tests unten - liefert nie echte Issues, kann aber auf Wunsch fehlschlagen. */
|
||||||
|
final class StubCodebergClient implements CodebergClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly bool $shouldFail = false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listIssues(string $owner, string $repo, string $state): array
|
||||||
|
{
|
||||||
|
if ($this->shouldFail) {
|
||||||
|
throw new RuntimeException('Codeberg-API nicht erreichbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class TicketControllerTest extends TestCase
|
final class TicketControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -19,7 +40,10 @@ final class TicketControllerTest extends TestCase
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->repository = new TicketRepository(Database::inMemory());
|
$this->repository = new TicketRepository(Database::inMemory());
|
||||||
$this->controller = new TicketController($this->repository);
|
$this->controller = new TicketController(
|
||||||
|
$this->repository,
|
||||||
|
new CodebergSyncService(new StubCodebergClient(), $this->repository, new RepoConfigRepository(Database::inMemory()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexReturnsAllTickets(): void
|
public function testIndexReturnsAllTickets(): void
|
||||||
|
|
@ -76,4 +100,35 @@ final class TicketControllerTest extends TestCase
|
||||||
['id' => '999']
|
['id' => '999']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testResyncRemovesOrphanedTicketOfConfiguredRepoAndReturnsNewBoardState(): void
|
||||||
|
{
|
||||||
|
// 'fds2610/monitor' ist eines der per Migration seedenden Default-Projekte (siehe
|
||||||
|
// 003_repo_configs.sql) - der StubCodebergClient liefert dafuer keine Issues, das
|
||||||
|
// Ticket ist also eine Karteileiche im Sinne von Issue #51.
|
||||||
|
$this->repository->create([
|
||||||
|
'title' => 'Karteileiche',
|
||||||
|
'repo' => 'fds2610/monitor',
|
||||||
|
'external_ref' => '1',
|
||||||
|
'source' => 'codeberg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->controller->resync(Request::create('POST', '/api/v1/tickets/resync'));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status);
|
||||||
|
self::assertSame([], $response->body()['tickets']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResyncReturns502WhenCodebergUnreachable(): void
|
||||||
|
{
|
||||||
|
$repository = new TicketRepository(Database::inMemory());
|
||||||
|
$controller = new TicketController(
|
||||||
|
$repository,
|
||||||
|
new CodebergSyncService(new StubCodebergClient(shouldFail: true), $repository, new RepoConfigRepository(Database::inMemory()))
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $controller->resync(Request::create('POST', '/api/v1/tickets/resync'));
|
||||||
|
|
||||||
|
self::assertSame(502, $response->status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,49 @@ final class CodebergSyncServiceTest extends TestCase
|
||||||
self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
|
self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testResyncRemovesTicketsThatNoLongerExistAtCodeberg(): void
|
||||||
|
{
|
||||||
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
|
$repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]);
|
||||||
|
$client = self::clientForMonitor([
|
||||||
|
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
|
||||||
|
'closed' => [],
|
||||||
|
]);
|
||||||
|
(new CodebergSyncService($client, $tickets, $repoConfigs))->sync();
|
||||||
|
// Manuell auf "fertig" bewegt, damit sync() es normalerweise unangetastet liesse
|
||||||
|
// (siehe testSyncDoesNotResetManuallyMovedTicket) - Issue #51 ist genau fuer solche
|
||||||
|
// liegen gebliebenen Karteileichen gedacht, die der normale Sync nicht mehr anfasst.
|
||||||
|
$orphanTicket = $tickets->create(['title' => 'Verwaist', 'repo' => self::MONITOR_SLUG, 'external_ref' => '2', 'column' => 'fertig']);
|
||||||
|
|
||||||
|
// Issue 1 wurde bei Codeberg inzwischen komplett geloescht (taucht weder offen noch
|
||||||
|
// geschlossen auf) - genau der Fall aus Issue #51, den der normale Sync nicht bereinigt.
|
||||||
|
(new CodebergSyncService(new FakeCodebergClient([self::MONITOR_SLUG => ['open' => [], 'closed' => []]]), $tickets, $repoConfigs))->resync();
|
||||||
|
|
||||||
|
self::assertNull($tickets->findByExternalRef('1', self::MONITOR_SLUG));
|
||||||
|
self::assertNull($tickets->find((int) $orphanTicket['id']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResyncKeepsManuallyMovedTicketWhoseIssueStillExistsAtCodeberg(): void
|
||||||
|
{
|
||||||
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
|
$repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]);
|
||||||
|
$client = self::clientForMonitor([
|
||||||
|
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
|
||||||
|
'closed' => [],
|
||||||
|
]);
|
||||||
|
(new CodebergSyncService($client, $tickets, $repoConfigs))->sync();
|
||||||
|
$ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||||
|
$tickets->update((int) $ticket['id'], ['column' => 'fertig']);
|
||||||
|
|
||||||
|
// Dasselbe Issue existiert bei Codeberg weiterhin (offen) - resync() darf es trotz
|
||||||
|
// manuell gesetzter, nicht vom Sync verwalteter Spalte nicht als Karteileiche werfen.
|
||||||
|
(new CodebergSyncService($client, $tickets, $repoConfigs))->resync();
|
||||||
|
|
||||||
|
$stillFertig = $tickets->findByExternalRef('1', self::MONITOR_SLUG);
|
||||||
|
self::assertNotNull($stillFertig);
|
||||||
|
self::assertSame('fertig', $stillFertig['column_name']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void
|
public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void
|
||||||
{
|
{
|
||||||
$tickets = new TicketRepository(Database::inMemory());
|
$tickets = new TicketRepository(Database::inMemory());
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,45 @@ header.office-header h1 {
|
||||||
stroke: #0284c7;
|
stroke: #0284c7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reload-Symbol neben der Board-Ueberschrift (Issue #51): stoesst ein komplettes
|
||||||
|
Neueinlesen des Kanban-Boards an, analog zum Zahnrad am PO-Platz. */
|
||||||
|
.board-reload-hint-bg {
|
||||||
|
fill: #ffffff;
|
||||||
|
stroke: #94a3b8;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-reload-hint-arrow {
|
||||||
|
fill: none;
|
||||||
|
stroke: #475569;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-reload-hint-arrowhead {
|
||||||
|
fill: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-reload-hint-hit {
|
||||||
|
fill: transparent;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-reload-hint:hover .board-reload-hint-bg {
|
||||||
|
fill: #e0f2fe;
|
||||||
|
stroke: #0284c7;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-reload-hint:hover .board-reload-hint-arrow {
|
||||||
|
stroke: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-reload-hint:hover .board-reload-hint-arrowhead {
|
||||||
|
fill: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
.just-updated .board-frame,
|
.just-updated .board-frame,
|
||||||
.just-updated .monitor-frame {
|
.just-updated .monitor-frame {
|
||||||
animation: office-flash 0.9s ease-out;
|
animation: office-flash 0.9s ease-out;
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,16 @@ export async function updateRepoConfig(id, data, adminKey) {
|
||||||
export async function deleteRepoConfig(id, adminKey) {
|
export async function deleteRepoConfig(id, adminKey) {
|
||||||
await adminJson("DELETE", `/repo-configs/${encodeURIComponent(id)}`, undefined, adminKey);
|
await adminJson("DELETE", `/repo-configs/${encodeURIComponent(id)}`, undefined, adminKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #51: "Board neu einlesen"-Knopf am Kanban-Board - loescht serverseitig alle Tickets
|
||||||
|
* und liest alle konfigurierten Codeberg-Projekte synchron neu ein. Braucht wie die
|
||||||
|
* Projekte-Verwaltung den Admin-Key, weil ein Voll-Reset des Boards potenziell teuer
|
||||||
|
* (mehrere Codeberg-Requests) und kein normaler Agenten-Vorgang ist.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<object>>} der neue Board-Zustand.
|
||||||
|
*/
|
||||||
|
export async function resyncTickets(adminKey) {
|
||||||
|
const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey);
|
||||||
|
return tickets;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import {
|
||||||
createRepoConfig,
|
createRepoConfig,
|
||||||
updateRepoConfig,
|
updateRepoConfig,
|
||||||
deleteRepoConfig,
|
deleteRepoConfig,
|
||||||
|
resyncTickets,
|
||||||
getStoredAdminKey,
|
getStoredAdminKey,
|
||||||
setStoredAdminKey,
|
setStoredAdminKey,
|
||||||
} from "./office-api.js";
|
} from "./office-api.js";
|
||||||
|
|
@ -208,14 +209,17 @@ function renderStackBadge(x, y, count, stackColors = []) {
|
||||||
* @param {Record<string, number>} ticketCounts
|
* @param {Record<string, number>} ticketCounts
|
||||||
* @param {Record<string, Array<object>>} groupedTickets - liefert die Projektfarbe je Zettel.
|
* @param {Record<string, Array<object>>} groupedTickets - liefert die Projektfarbe je Zettel.
|
||||||
* @param {(columnKey: string) => void} onColumnClick
|
* @param {(columnKey: string) => void} onColumnClick
|
||||||
|
* @param {() => void} onReloadClick - Issue #51: Klick auf das Reload-Symbol neben der
|
||||||
|
* Ueberschrift stoesst ein komplettes Neueinlesen des Boards an.
|
||||||
*/
|
*/
|
||||||
function renderBoard(ticketCounts, groupedTickets, onColumnClick) {
|
function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) {
|
||||||
const columns = buildBoardColumnLayout(ticketCounts, groupedTickets);
|
const columns = buildBoardColumnLayout(ticketCounts, groupedTickets);
|
||||||
const g = el("g", { class: "board" });
|
const g = el("g", { class: "board" });
|
||||||
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
|
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" })
|
text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" })
|
||||||
);
|
);
|
||||||
|
g.appendChild(renderBoardReloadHint(onReloadClick));
|
||||||
|
|
||||||
const columnWidth = BOARD_FRAME.w / columns.length;
|
const columnWidth = BOARD_FRAME.w / columns.length;
|
||||||
|
|
||||||
|
|
@ -279,6 +283,42 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick) {
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #51: das Reload-Symbol sitzt neben der Board-Ueberschrift, analog zum Zahnrad am
|
||||||
|
// PO-Platz (renderPoConfigHint) - selbe "aus Grundformen statt Icon-Bibliothek gebaute
|
||||||
|
// Silhouette" plus Hover-Hervorhebung, damit beide Hinweise als ein visuelles System wirken.
|
||||||
|
function renderBoardReloadHint(onClick) {
|
||||||
|
const cx = BOARD_FRAME.x + 210;
|
||||||
|
const cy = BOARD_FRAME.y - 21;
|
||||||
|
const g = el("g", { class: "board-reload-hint clickable", transform: `translate(${cx}, ${cy})` });
|
||||||
|
|
||||||
|
const title = document.createElementNS(SVG_NS, "title");
|
||||||
|
title.textContent = "Kanban-Board komplett neu einlesen";
|
||||||
|
g.appendChild(title);
|
||||||
|
|
||||||
|
g.appendChild(el("circle", { cx: 0, cy: 0, r: 13, class: "board-reload-hint-bg" }));
|
||||||
|
// Kreisbogen mit Luecke (grosser Bogen, eine Richtung) plus Pfeilspitze an dessen Anfang -
|
||||||
|
// ergibt in dieser Groesse ein erkennbares "Reload"-Symbol.
|
||||||
|
g.appendChild(
|
||||||
|
el("path", {
|
||||||
|
d: "M 6.5 -4 A 7.5 7.5 0 1 1 -7.2 -2.6",
|
||||||
|
class: "board-reload-hint-arrow",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
g.appendChild(
|
||||||
|
el("polygon", { points: "-7.2,-8.4 -1.4,-2.6 -9.8,-0.4", class: "board-reload-hint-arrowhead" })
|
||||||
|
);
|
||||||
|
// Groessere unsichtbare Trefferflaeche, analog zu .po-desk-hit - das sichtbare Symbol ist
|
||||||
|
// fuer einen zuverlaessigen Klick zu klein.
|
||||||
|
g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "board-reload-hint-hit" }));
|
||||||
|
|
||||||
|
g.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
function renderEventMonitor(frame, events) {
|
function renderEventMonitor(frame, events) {
|
||||||
const lines = buildEventLines(events).map(mapEventForDisplay);
|
const lines = buildEventLines(events).map(mapEventForDisplay);
|
||||||
const g = el("g", { class: "monitor monitor-events" });
|
const g = el("g", { class: "monitor monitor-events" });
|
||||||
|
|
@ -993,6 +1033,76 @@ function triggerPoVisit() {
|
||||||
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
|
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #51: Board-Besuch fuer den manuellen Reload-Knopf. Anders als triggerPoVisit()/
|
||||||
|
// advancePoVisit() (feste Dauer je Stage) bleibt der PO in "interacting_with_board", bis die
|
||||||
|
// uebergebene Aktion abgeschlossen ist - genau das macht die Standzeit am Board zur sichtbaren
|
||||||
|
// Anzeige, wie lange das Neueinlesen tatsaechlich gedauert hat (Issue-Text: "daran kann man
|
||||||
|
// erkennen, wie lange er dafuer benoetigt"). "walking_to_board" und "walking_back" behalten
|
||||||
|
// weiterhin ihre feste Animationsdauer.
|
||||||
|
//
|
||||||
|
// Ist der PO gerade anderweitig unterwegs (poState !== "idle"), wird kein zweiter Besuch
|
||||||
|
// angestossen - die Aktion laeuft trotzdem, nur ohne eigene Animation.
|
||||||
|
function triggerPoReloadVisit(action) {
|
||||||
|
if (!poIsLive || poState !== "idle") {
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
poState = PO_VISIT_STAGES[0]; // walking_to_board
|
||||||
|
repaintAgentsLayer();
|
||||||
|
poVisitTimeoutId = window.setTimeout(async () => {
|
||||||
|
poState = PO_VISIT_STAGES[1]; // interacting_with_board
|
||||||
|
repaintAgentsLayer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
poState = PO_VISIT_STAGES[2]; // walking_back
|
||||||
|
repaintAgentsLayer();
|
||||||
|
poVisitTimeoutId = window.setTimeout(() => {
|
||||||
|
poState = "idle";
|
||||||
|
repaintAgentsLayer();
|
||||||
|
}, DOOR_VISIT_STAGE_DURATION_MS);
|
||||||
|
}
|
||||||
|
}, DOOR_VISIT_STAGE_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird von boot() gesetzt: laedt den Board-Zustand sofort neu, statt auf den naechsten
|
||||||
|
// SSE-Push/Sicherheitsnetz-Poll zu warten (siehe office-realtime.js) - nach einem manuellen
|
||||||
|
// Reload soll das Board unmittelbar den neuen Stand zeigen.
|
||||||
|
let requestSceneRefresh = null;
|
||||||
|
let boardResyncInFlight = false;
|
||||||
|
|
||||||
|
async function handleBoardResync() {
|
||||||
|
if (boardResyncInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let adminKey = getStoredAdminKey();
|
||||||
|
if (!adminKey) {
|
||||||
|
adminKey = window.prompt("Admin-Key für das Neueinlesen des Boards:") || "";
|
||||||
|
if (!adminKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boardResyncInFlight = true;
|
||||||
|
triggerPoReloadVisit(async () => {
|
||||||
|
try {
|
||||||
|
await resyncTickets(adminKey);
|
||||||
|
setStoredAdminKey(adminKey);
|
||||||
|
await requestSceneRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.isAuthError) {
|
||||||
|
setStoredAdminKey("");
|
||||||
|
}
|
||||||
|
window.alert(err instanceof Error ? err.message : "Board konnte nicht neu eingelesen werden.");
|
||||||
|
} finally {
|
||||||
|
boardResyncInFlight = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function advanceDoorVisit(id) {
|
function advanceDoorVisit(id) {
|
||||||
const visitor = doorVisitors.get(id);
|
const visitor = doorVisitors.get(id);
|
||||||
if (!visitor) {
|
if (!visitor) {
|
||||||
|
|
@ -1502,12 +1612,17 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
|
|
||||||
const groupedTickets = groupTicketsByColumn(tickets);
|
const groupedTickets = groupTicketsByColumn(tickets);
|
||||||
|
|
||||||
const boardGroup = renderBoard(ticketCounts, groupedTickets, (columnKey) => {
|
const boardGroup = renderBoard(
|
||||||
openModal(
|
ticketCounts,
|
||||||
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
|
groupedTickets,
|
||||||
renderTicketModalBody
|
(columnKey) => {
|
||||||
);
|
openModal(
|
||||||
});
|
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
|
||||||
|
renderTicketModalBody
|
||||||
|
);
|
||||||
|
},
|
||||||
|
handleBoardResync
|
||||||
|
);
|
||||||
const existingBoard = svg.querySelector(".board");
|
const existingBoard = svg.querySelector(".board");
|
||||||
if (existingBoard) {
|
if (existingBoard) {
|
||||||
existingBoard.replaceWith(boardGroup);
|
existingBoard.replaceWith(boardGroup);
|
||||||
|
|
@ -1700,6 +1815,8 @@ async function boot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestSceneRefresh = refresh;
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
startRealtimeUpdates(refresh);
|
startRealtimeUpdates(refresh);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue