Reload-Knopf am Kanban-Board: Tickets komplett neu einlesen (Issue #51)
Manchmal werden bei Codeberg geloeschte oder anderweitig verschwundene Issues vom periodischen Sync nicht bereinigt (er upsertet nur, ruehrt fehlende Issues nie an). Neuer Knopf neben der "Kanban-Board"-Ueberschrift stoesst dafuer einen synchronen Voll-Resync an (POST /api/v1/tickets/resync, X-Admin-Key): jedes konfigurierte Projekt wird neu gelesen, Tickets zu inzwischen verschwundenen Issues werden je Projekt entfernt (auch wenn sie manuell auf eine nicht vom Sync verwaltete Spalte wie "fertig" bewegt wurden), Tickets zu weiterhin existierenden Issues bleiben unangetastet. Der PO geht dabei sichtbar ans Board und steht dort, bis die Anfrage abgeschlossen ist - anders als beim automatischen Board-Besuch (feste Animationsdauer je Stage) macht das die tatsaechliche Sync-Dauer sichtbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a5a5df0ecd
commit
bd27923321
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