diff --git a/.env.example b/.env.example index fd314ce..1755fb4 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,10 @@ MONITOR_API_BASE=http://localhost:8091/api/v1 # oben), das Modal fragt den Key bei der ersten Aenderung einmalig ab. # Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind - bei kleinem -# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert +# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert. Fuer die Konfig-Pruefung im +# Projekte-Modal (Issue #53: "POST /api/v1/repo-configs/{id}/check") braucht der Account +# zusaetzlich Schreib- bzw. Admin-Rechte auf die jeweiligen Projekte, sonst schlagen die +# Checks fuer Schreibrechte bzw. Webhook dort fehl. CODEBERG_TOKEN= # Abstand zwischen zwei periodischen Codeberg-Sync-Laeufen in Sekunden (Service "sync" in diff --git a/API.md b/API.md index 70baa3d..a975928 100644 --- a/API.md +++ b/API.md @@ -352,12 +352,37 @@ bisherigen Default-Projekte `fds2610/monitor` (gelb), `fds2610/Steuerkram` (grue | `POST /api/v1/repo-configs` | `X-Admin-Key` | Legt ein Projekt an (`owner`, `repo`, `color` als 6-stelliger Hex-Wert, mit oder ohne `#`) | | `PATCH /api/v1/repo-configs/{id}` | `X-Admin-Key` | Aendert `owner`/`repo`/`color` eines Projekts | | `DELETE /api/v1/repo-configs/{id}` | `X-Admin-Key` | Entfernt ein Projekt (der Sync liest es danach nicht mehr) und loescht dessen Tickets aus dem Kanban (Issue #43) | +| `POST /api/v1/repo-configs/{id}/check` | `X-Admin-Key` | Prueft Schreibrechte + Webhook des Projekts (Issue #53, siehe unten) | `owner`+`repo` muessen zusammen eindeutig sein (409 -> hier als `422` mit Fehlermeldung, da kein Konflikt-Statuscode im Fehlerformat vorgesehen ist). Der laufende `sync`-Dienst (`--watch`) liest die Projektliste bei jedem Lauf frisch ein - eine Aenderung wirkt also ohne Neustart des Containers. +### `POST /api/v1/repo-configs/{id}/check` — Schreibrechte + Webhook pruefen + +Issue #53: Das Frontend-Modal ruft diesen Endpunkt automatisch nach dem Anlegen/Bearbeiten +eines Projekts auf und zeigt das Ergebnis als gruenen Haken bzw. rotes Kreuz rechts neben den +Bearbeiten-/Loeschen-Symbolen (Tooltip = `message`). Beide Checks nutzen `CODEBERG_TOKEN` +(das Konto, unter dem der Sync laeuft): + +```json +// Response 200 +{ + "checks": { + "write_access": { "ok": true, "message": "fds2610-bot ist Mitarbeiter mit Schreibrechten auf dieses Projekt." }, + "webhook": { "ok": false, "message": "Kein aktiver Webhook auf \"/api/v1/webhooks/codeberg\" gefunden - unter Einstellungen -> Webhooks einrichten." } + } +} +``` + +- `write_access`: liest `permissions.push` aus `GET /repos/{owner}/{repo}` - erfordert + lediglich, dass das Projekt fuer den Token sichtbar ist. +- `webhook`: listet `GET /repos/{owner}/{repo}/hooks` und sucht einen aktiven Hook mit + `content_type: json`, dessen URL auf `/api/v1/webhooks/codeberg` endet. Das Auflisten von + Hooks erfordert Admin-Rechte auf das Projekt - fehlen die, liefert der Check `ok: false` mit + entsprechender Meldung statt eines Fehlers. + ## Codeberg-Sync (Backlog/Done) `api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten diff --git a/api/src/Application.php b/api/src/Application.php index 3a964c5..9a94495 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -33,6 +33,7 @@ use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergSyncService; use Monitor\Api\Sync\CurlCodebergClient; +use Monitor\Api\Sync\RepoHealthChecker; use Monitor\Api\Sync\SyncTrigger; use Throwable; @@ -66,16 +67,13 @@ final class Application // 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, - ); + $codebergClient = new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()); + $syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs); $ticketController = new TicketController($tickets, $syncService); $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $kpiController = new KpiController($kpis); - $repoConfigController = new RepoConfigController($repoConfigs, $tickets); + $repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient)); $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks); $streamController = new StreamController( new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks), @@ -109,6 +107,10 @@ final class Application $this->router->add('POST', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->create($r), 'admin'); $this->router->add('PATCH', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->update($r, $p), 'admin'); $this->router->add('DELETE', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->delete($r, $p), 'admin'); + // Issue #53: Konfig-Pruefung (Schreibrechte + Webhook) nach dem Anlegen/Bearbeiten eines + // Projekts im Frontend-Modal - schreibend wie die uebrigen Projekte-Endpunkte, da jeder + // Aufruf zwei zusaetzliche Codeberg-Requests ausloest. + $this->router->add('POST', '/api/v1/repo-configs/{id}/check', fn (Request $r, array $p) => $repoConfigController->check($r, $p), 'admin'); $this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r)); $this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin'); diff --git a/api/src/Controller/RepoConfigController.php b/api/src/Controller/RepoConfigController.php index 0afd198..0989444 100644 --- a/api/src/Controller/RepoConfigController.php +++ b/api/src/Controller/RepoConfigController.php @@ -5,16 +5,19 @@ declare(strict_types=1); namespace Monitor\Api\Controller; use InvalidArgumentException; +use Monitor\Api\Http\NotFoundException; use Monitor\Api\Http\Request; use Monitor\Api\Http\Response; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Sync\RepoHealthChecker; final class RepoConfigController { public function __construct( private readonly RepoConfigRepository $repoConfigs, private readonly TicketRepository $tickets, + private readonly RepoHealthChecker $healthChecker, ) { } @@ -59,4 +62,24 @@ final class RepoConfigController return Response::json(['deleted' => true]); } + + /** + * Issue #53: Prueft nach dem Anlegen/Bearbeiten eines Projekts im Konfig-Dialog, ob + * fds2610-bot dort Schreibrechte hat und ob ein Webhook auf diese monitor-Instanz zeigt. + * Schreibend wie die uebrigen Projekte-Endpunkte (Admin-Key), da jeder Aufruf zwei + * zusaetzliche Codeberg-Requests ausloest. + * + * @param array $params + */ + public function check(Request $request, array $params): Response + { + $repo = $this->repoConfigs->find((int) $params['id']); + if ($repo === null) { + throw new NotFoundException('Projekt nicht gefunden: ' . $params['id']); + } + + return Response::json([ + 'checks' => $this->healthChecker->check($repo['owner'], $repo['repo']), + ]); + } } diff --git a/api/src/Sync/CodebergApiException.php b/api/src/Sync/CodebergApiException.php new file mode 100644 index 0000000..e799779 --- /dev/null +++ b/api/src/Sync/CodebergApiException.php @@ -0,0 +1,22 @@ +}> */ public function listIssues(string $owner, string $repo, string $state): array; + + /** + * Issue #53: Berechtigungen des konfigurierten CODEBERG_TOKEN auf ein Projekt + * (GET /repos/{owner}/{repo}, Feld "permissions") - Grundlage fuer die Pruefung, ob der + * Bot dort Schreibrechte hat. Wirft CodebergApiException bei HTTP-Fehlern (z.B. 404, + * wenn das Projekt nicht existiert oder fuer den Token nicht sichtbar ist). + * + * @return array{push: bool} + */ + public function repoPermissions(string $owner, string $repo): array; + + /** + * Issue #53: Eingerichtete Webhooks eines Projekts (GET /repos/{owner}/{repo}/hooks). + * Erfordert Admin-Rechte auf das Projekt - ohne die antwortet Codeberg mit 403 + * (CodebergApiException). + * + * @return array + */ + public function listWebhooks(string $owner, string $repo): array; } diff --git a/api/src/Sync/CurlCodebergClient.php b/api/src/Sync/CurlCodebergClient.php index 808bf38..6ab2b47 100644 --- a/api/src/Sync/CurlCodebergClient.php +++ b/api/src/Sync/CurlCodebergClient.php @@ -60,7 +60,31 @@ final class CurlCodebergClient implements CodebergClient return $issues; } - /** @return array> */ + public function repoPermissions(string $owner, string $repo): array + { + $url = sprintf('%s/repos/%s/%s', $this->apiBase, rawurlencode($owner), rawurlencode($repo)); + $data = $this->fetch($url); + $permissions = is_array($data['permissions'] ?? null) ? $data['permissions'] : []; + + return ['push' => (bool) ($permissions['push'] ?? false)]; + } + + public function listWebhooks(string $owner, string $repo): array + { + $url = sprintf('%s/repos/%s/%s/hooks', $this->apiBase, rawurlencode($owner), rawurlencode($repo)); + $data = $this->fetch($url); + + return array_map( + static fn (array $hook): array => [ + 'active' => (bool) ($hook['active'] ?? false), + 'url' => (string) ($hook['config']['url'] ?? ''), + 'content_type' => (string) ($hook['config']['content_type'] ?? ''), + ], + $data + ); + } + + /** @return array */ private function fetch(string $url): array { $headers = ['Accept: application/json']; @@ -83,7 +107,7 @@ final class CurlCodebergClient implements CodebergClient throw new RuntimeException('Codeberg-API nicht erreichbar: ' . $error); } if ($status >= 400) { - throw new RuntimeException('Codeberg-API-Fehler (HTTP ' . $status . '): ' . $body); + throw new CodebergApiException($status, 'Codeberg-API-Fehler (HTTP ' . $status . '): ' . $body); } $decoded = json_decode((string) $body, true); diff --git a/api/src/Sync/RepoHealthChecker.php b/api/src/Sync/RepoHealthChecker.php new file mode 100644 index 0000000..30320e0 --- /dev/null +++ b/api/src/Sync/RepoHealthChecker.php @@ -0,0 +1,102 @@ + $this->checkWriteAccess($owner, $repo), + 'webhook' => $this->checkWebhook($owner, $repo), + ]; + } + + /** @return array{ok: bool, message: string} */ + private function checkWriteAccess(string $owner, string $repo): array + { + try { + $permissions = $this->client->repoPermissions($owner, $repo); + } catch (CodebergApiException $e) { + return ['ok' => false, 'message' => $this->explainApiError($owner, $repo, $e)]; + } catch (RuntimeException $e) { + return ['ok' => false, 'message' => 'Codeberg nicht erreichbar: ' . $e->getMessage()]; + } + + if ($permissions['push']) { + return ['ok' => true, 'message' => 'fds2610-bot ist Mitarbeiter mit Schreibrechten auf dieses Projekt.']; + } + + return [ + 'ok' => false, + 'message' => 'fds2610-bot hat keine Schreibrechte auf dieses Projekt - als Mitarbeiter mit Schreibzugriff eintragen.', + ]; + } + + /** @return array{ok: bool, message: string} */ + private function checkWebhook(string $owner, string $repo): array + { + try { + $hooks = $this->client->listWebhooks($owner, $repo); + } catch (CodebergApiException $e) { + if ($e->status === 403) { + return [ + 'ok' => false, + 'message' => 'Webhook kann nicht geprueft werden - fds2610-bot hat keine Admin-Rechte auf dieses Projekt.', + ]; + } + + return ['ok' => false, 'message' => $this->explainApiError($owner, $repo, $e)]; + } catch (RuntimeException $e) { + return ['ok' => false, 'message' => 'Codeberg nicht erreichbar: ' . $e->getMessage()]; + } + + foreach ($hooks as $hook) { + if ($hook['active'] && $hook['content_type'] === 'json' && str_ends_with($hook['url'], self::WEBHOOK_PATH)) { + return ['ok' => true, 'message' => 'Webhook ist eingerichtet und aktiv.']; + } + } + + return [ + 'ok' => false, + 'message' => sprintf( + 'Kein aktiver Webhook auf "%s" gefunden - unter Einstellungen -> Webhooks einrichten.', + self::WEBHOOK_PATH + ), + ]; + } + + private function explainApiError(string $owner, string $repo, CodebergApiException $e): string + { + if ($e->status === 404) { + return sprintf('Projekt "%s/%s" auf Codeberg nicht gefunden.', $owner, $repo); + } + + return 'Codeberg-API-Fehler (HTTP ' . $e->status . ').'; + } +} diff --git a/api/tests/Unit/Controller/RepoConfigControllerTest.php b/api/tests/Unit/Controller/RepoConfigControllerTest.php index 6ac3d68..8604bc5 100644 --- a/api/tests/Unit/Controller/RepoConfigControllerTest.php +++ b/api/tests/Unit/Controller/RepoConfigControllerTest.php @@ -6,9 +6,12 @@ namespace Monitor\Api\Tests\Controller; use Monitor\Api\Controller\RepoConfigController; use Monitor\Api\Db\Database; +use Monitor\Api\Http\NotFoundException; use Monitor\Api\Http\Request; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Sync\CodebergClient; +use Monitor\Api\Sync\RepoHealthChecker; use PHPUnit\Framework\TestCase; final class RepoConfigControllerTest extends TestCase @@ -22,7 +25,23 @@ final class RepoConfigControllerTest extends TestCase $db->pdo()->exec('DELETE FROM repo_configs'); $db->pdo()->exec('DELETE FROM tickets'); $this->tickets = new TicketRepository($db); - $this->controller = new RepoConfigController(new RepoConfigRepository($db), $this->tickets); + $healthChecker = new RepoHealthChecker(new class implements CodebergClient { + public function listIssues(string $owner, string $repo, string $state): array + { + return []; + } + + public function repoPermissions(string $owner, string $repo): array + { + return ['push' => true]; + } + + public function listWebhooks(string $owner, string $repo): array + { + return [['active' => true, 'url' => 'https://monitor.example.org/api/v1/webhooks/codeberg', 'content_type' => 'json']]; + } + }); + $this->controller = new RepoConfigController(new RepoConfigRepository($db), $this->tickets, $healthChecker); } public function testIndexReturnsEmptyListInitially(): void @@ -103,4 +122,30 @@ final class RepoConfigControllerTest extends TestCase self::assertNotNull($this->tickets->find((int) $unrelated['id'])); self::assertNotNull($this->tickets->find((int) $manual['id'])); } + + public function testCheckReturnsWriteAccessAndWebhookResults(): void + { + $created = $this->controller->create(Request::create('POST', '/api/v1/repo-configs', body: [ + 'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04', + ]))->body()['repo']; + + $response = $this->controller->check( + Request::create('POST', '/api/v1/repo-configs/' . $created['id'] . '/check'), + ['id' => (string) $created['id']] + ); + + self::assertSame(200, $response->status); + self::assertTrue($response->body()['checks']['write_access']['ok']); + self::assertTrue($response->body()['checks']['webhook']['ok']); + } + + public function testCheckThrowsNotFoundForUnknownRepo(): void + { + $this->expectException(NotFoundException::class); + + $this->controller->check( + Request::create('POST', '/api/v1/repo-configs/999/check'), + ['id' => '999'] + ); + } } diff --git a/api/tests/Unit/Sync/CurlCodebergClientTest.php b/api/tests/Unit/Sync/CurlCodebergClientTest.php index e478987..fa33c64 100644 --- a/api/tests/Unit/Sync/CurlCodebergClientTest.php +++ b/api/tests/Unit/Sync/CurlCodebergClientTest.php @@ -57,6 +57,7 @@ namespace Monitor\Api\Sync { } namespace Monitor\Api\Tests\Sync { + use Monitor\Api\Sync\CodebergApiException; use Monitor\Api\Sync\CurlCodebergClient; use Monitor\Api\Sync\CurlStub; use PHPUnit\Framework\TestCase; @@ -118,5 +119,71 @@ namespace Monitor\Api\Tests\Sync { (new CurlCodebergClient('https://codeberg.org/api/v1'))->listIssues('fds2610', 'monitor', 'open'); } + + public function testRepoPermissionsReadsPushFlag(): void + { + CurlStub::$queue = [ + ['status' => 200, 'body' => json_encode(['permissions' => ['admin' => false, 'push' => true, 'pull' => true]])], + ]; + + $client = new CurlCodebergClient('https://codeberg.org/api/v1'); + $permissions = $client->repoPermissions('fds2610', 'monitor'); + + self::assertTrue($permissions['push']); + self::assertStringContainsString('/repos/fds2610/monitor', CurlStub::$requestedUrls[0]); + } + + public function testRepoPermissionsDefaultsToFalseWithoutPermissionsField(): void + { + CurlStub::$queue = [['status' => 200, 'body' => json_encode(['id' => 1])]]; + + $permissions = (new CurlCodebergClient('https://codeberg.org/api/v1'))->repoPermissions('fds2610', 'monitor'); + + self::assertFalse($permissions['push']); + } + + public function testRepoPermissionsThrowsCodebergApiExceptionWithStatus(): void + { + CurlStub::$queue = [['status' => 404, 'body' => 'not found']]; + + try { + (new CurlCodebergClient('https://codeberg.org/api/v1'))->repoPermissions('fds2610', 'unbekannt'); + self::fail('Erwartete CodebergApiException wurde nicht geworfen.'); + } catch (CodebergApiException $e) { + self::assertSame(404, $e->status); + } + } + + public function testListWebhooksParsesActiveUrlAndContentType(): void + { + CurlStub::$queue = [ + ['status' => 200, 'body' => json_encode([ + ['active' => true, 'config' => ['url' => 'https://example.org/api/v1/webhooks/codeberg', 'content_type' => 'json']], + ['active' => false, 'config' => ['url' => 'https://example.org/other', 'content_type' => 'form']], + ])], + ]; + + $client = new CurlCodebergClient('https://codeberg.org/api/v1'); + $hooks = $client->listWebhooks('fds2610', 'monitor'); + + self::assertCount(2, $hooks); + self::assertTrue($hooks[0]['active']); + self::assertSame('https://example.org/api/v1/webhooks/codeberg', $hooks[0]['url']); + self::assertSame('json', $hooks[0]['content_type']); + self::assertFalse($hooks[1]['active']); + self::assertStringContainsString('/hooks', CurlStub::$requestedUrls[0]); + } + + public function testListWebhooksThrowsCodebergApiExceptionOn403(): void + { + CurlStub::$queue = [['status' => 403, 'body' => 'forbidden']]; + + try { + (new CurlCodebergClient('https://codeberg.org/api/v1'))->listWebhooks('fds2610', 'monitor'); + self::fail('Erwartete CodebergApiException wurde nicht geworfen.'); + } catch (CodebergApiException $e) { + self::assertSame(403, $e->status); + } + } } } diff --git a/api/tests/Unit/Sync/RepoHealthCheckerTest.php b/api/tests/Unit/Sync/RepoHealthCheckerTest.php new file mode 100644 index 0000000..0268562 --- /dev/null +++ b/api/tests/Unit/Sync/RepoHealthCheckerTest.php @@ -0,0 +1,130 @@ +fakeClient(repoPermissions: ['push' => true]); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'monitor'); + + self::assertTrue($checks['write_access']['ok']); + } + + public function testWriteAccessFailsWhenPushPermissionFalse(): void + { + $client = $this->fakeClient(repoPermissions: ['push' => false]); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'monitor'); + + self::assertFalse($checks['write_access']['ok']); + self::assertStringContainsString('keine Schreibrechte', $checks['write_access']['message']); + } + + public function testWriteAccessFailsWithNotFoundMessageOn404(): void + { + $client = $this->fakeClient(repoPermissionsException: new CodebergApiException(404, 'nope')); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'unbekannt'); + + self::assertFalse($checks['write_access']['ok']); + self::assertStringContainsString('nicht gefunden', $checks['write_access']['message']); + } + + public function testWebhookOkWhenActiveJsonHookPointsToOwnEndpoint(): void + { + $client = $this->fakeClient(listWebhooks: [ + ['active' => true, 'url' => 'https://monitor.example.org/api/v1/webhooks/codeberg', 'content_type' => 'json'], + ]); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'monitor'); + + self::assertTrue($checks['webhook']['ok']); + } + + public function testWebhookFailsWhenNoMatchingHookPresent(): void + { + $client = $this->fakeClient(listWebhooks: [ + ['active' => true, 'url' => 'https://example.org/other', 'content_type' => 'json'], + ['active' => false, 'url' => 'https://monitor.example.org/api/v1/webhooks/codeberg', 'content_type' => 'json'], + ]); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'monitor'); + + self::assertFalse($checks['webhook']['ok']); + } + + public function testWebhookFailsWithPermissionMessageOn403(): void + { + $client = $this->fakeClient(listWebhooksException: new CodebergApiException(403, 'forbidden')); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'monitor'); + + self::assertFalse($checks['webhook']['ok']); + self::assertStringContainsString('keine Admin-Rechte', $checks['webhook']['message']); + } + + public function testFailsGracefullyWhenCodebergUnreachable(): void + { + $client = $this->fakeClient(repoPermissionsException: new RuntimeException('Codeberg-API nicht erreichbar: timeout')); + + $checks = (new RepoHealthChecker($client))->check('fds2610', 'monitor'); + + self::assertFalse($checks['write_access']['ok']); + self::assertStringContainsString('nicht erreichbar', $checks['write_access']['message']); + } + + /** + * @param array{push: bool}|null $repoPermissions + * @param array|null $listWebhooks + */ + private function fakeClient( + ?array $repoPermissions = null, + ?RuntimeException $repoPermissionsException = null, + ?array $listWebhooks = null, + ?RuntimeException $listWebhooksException = null, + ): CodebergClient { + return new class ($repoPermissions, $repoPermissionsException, $listWebhooks, $listWebhooksException) implements CodebergClient { + public function __construct( + private readonly ?array $repoPermissions, + private readonly ?RuntimeException $repoPermissionsException, + private readonly ?array $listWebhooks, + private readonly ?RuntimeException $listWebhooksException, + ) { + } + + public function listIssues(string $owner, string $repo, string $state): array + { + return []; + } + + public function repoPermissions(string $owner, string $repo): array + { + if ($this->repoPermissionsException !== null) { + throw $this->repoPermissionsException; + } + + return $this->repoPermissions ?? ['push' => true]; + } + + public function listWebhooks(string $owner, string $repo): array + { + if ($this->listWebhooksException !== null) { + throw $this->listWebhooksException; + } + + return $this->listWebhooks ?? []; + } + }; + } +} diff --git a/html/css/office.css b/html/css/office.css index 7aaf2fb..656a843 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -850,3 +850,28 @@ header.office-header h1 { border-color: #bbf7d0; color: #15803d; } + +/* Issue #53: Ampel-Symbole (Schreibrechte/Webhook) rechts neben den Bearbeiten-/ + Loeschen-Symbolen einer Projektzeile - Tooltip (title) traegt den Pruefgrund. */ +.repo-config-status { + display: flex; + gap: 2px; + flex: none; +} + +.repo-config-status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + cursor: help; +} + +.repo-config-status-icon.is-ok { + color: #16a34a; +} + +.repo-config-status-icon.is-fail { + color: #dc2626; +} diff --git a/html/js/office-api.js b/html/js/office-api.js index c167daf..6cdbf0b 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -143,6 +143,17 @@ export async function deleteRepoConfig(id, adminKey) { await adminJson("DELETE", `/repo-configs/${encodeURIComponent(id)}`, undefined, adminKey); } +/** + * Issue #53: Prueft nach dem Anlegen/Bearbeiten eines Projekts, ob fds2610-bot dort + * Schreibrechte hat und ob der Webhook korrekt eingerichtet ist. + * + * @returns {Promise<{write_access: {ok: boolean, message: string}, webhook: {ok: boolean, message: string}}>} + */ +export async function checkRepoConfig(id, adminKey) { + const { checks } = await adminJson("POST", `/repo-configs/${encodeURIComponent(id)}/check`, undefined, adminKey); + return checks; +} + /** * 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 diff --git a/html/js/office-render.js b/html/js/office-render.js index 9612e39..47c08cd 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -67,6 +67,7 @@ import { createRepoConfig, updateRepoConfig, deleteRepoConfig, + checkRepoConfig, resyncTickets, getStoredAdminKey, setStoredAdminKey, @@ -1266,11 +1267,22 @@ const REPO_CONFIG_ICONS = { cancel: "M18 6 6 18M6 6l12 12", }; +// Issue #53: Symbole fuer die Konfig-Pruefung (Schreibrechte + Webhook) rechts neben den +// Bearbeiten-/Loeschen-Symbolen einer Projektzeile - gruener Haken bzw. rotes Kreuz je Check. +const REPO_CHECK_ICONS = { + ok: "M20 6 9 17l-5-5", + fail: "M18 6 6 18M6 6l12 12", +}; + let repoModalBodyEl = null; let repoModalToken = null; let repoModalRepos = []; let repoModalMode = null; // null | "new" | let repoModalError = ""; +// Issue #53: Ergebnis der Konfig-Pruefung je Projekt-Id, ausschliesslich fuer die Dauer der +// Modal-Sitzung (kein Server-State) - { write_access: {ok, message}, webhook: {ok, message} }. +// Fehlt ein Eintrag, wurde das Projekt in dieser Sitzung noch nicht (erfolgreich) geprueft. +let repoModalChecks = {}; function iconButton(kind, title, onClick, extraClass = "") { const button = document.createElement("button"); @@ -1298,6 +1310,7 @@ function openRepoConfigModal() { repoModalRepos = []; repoModalMode = null; repoModalError = ""; + repoModalChecks = {}; repoModalToken = openModal({ title: "Projekte" }, (body) => { repoModalBodyEl = body; renderRepoConfigModal(); @@ -1394,9 +1407,47 @@ function renderRepoConfigRow(repo) { ); li.appendChild(actions); + const checks = repoModalChecks[repo.id]; + if (checks && checks.status === "done") { + const status = document.createElement("span"); + status.className = "repo-config-status"; + status.appendChild(statusIcon("Schreibrechte", checks.writeAccess)); + status.appendChild(statusIcon("Webhook", checks.webhook)); + li.appendChild(status); + } + return li; } +/** + * Issue #53: einzelnes Ampel-Symbol (Haken/Kreuz) fuer eine Konfig-Pruefung. Der Tooltip + * (title/aria-label) zeigt im Erfolgsfall, welcher Service geprueft wurde, im Fehlerfall den + * konkreten Grund - beides liefert bereits RepoHealthChecker als "message". + * + * @param {string} label + * @param {{ok: boolean, message: string}} result + */ +function statusIcon(label, result) { + const span = document.createElement("span"); + span.className = `repo-config-status-icon ${result.ok ? "is-ok" : "is-fail"}`; + const tooltip = `${label}: ${result.message}`; + span.title = tooltip; + span.setAttribute("aria-label", tooltip); + span.appendChild( + el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [ + el("path", { + d: result.ok ? REPO_CHECK_ICONS.ok : REPO_CHECK_ICONS.fail, + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + }), + ]) + ); + return span; +} + function renderRepoConfigForm(repo) { const li = document.createElement("li"); li.className = "office-modal-item repo-config-item repo-config-form"; @@ -1468,19 +1519,40 @@ async function handleRepoConfigSave(existing, data) { return; } - const ok = await withAdminKey((adminKey) => + const saved = await withAdminKeyResult((adminKey) => existing ? updateRepoConfig(existing.id, data, adminKey) : createRepoConfig(data, adminKey) ); if (!isCurrentModal(token)) { return; } - if (!ok) { + if (!saved) { renderRepoConfigModal(); return; } repoModalMode = null; await loadRepoConfigModal(token); + // Issue #53: nach dem Speichern pruefen, ob fds2610-bot Schreibrechte hat und der Webhook + // korrekt eingerichtet ist - Ergebnis erscheint als Haken/Kreuz rechts neben den Symbolen. + await runRepoConfigCheck(saved.id); +} + +/** + * Issue #53: fuehrt die Konfig-Pruefung (Schreibrechte + Webhook) fuer ein Projekt aus und + * merkt das Ergebnis in repoModalChecks fuer renderRepoConfigRow(). Schlaegt schon der + * Admin-Key-Rundtrip fehl (z.B. Prompt abgebrochen), bleibt das Projekt einfach ungeprueft + * (kein Eintrag) statt faelschlich ein rotes Kreuz zur Projekt-Gesundheit zu zeigen. + */ +async function runRepoConfigCheck(repoId) { + const token = repoModalToken; + const checks = await withAdminKeyResult((adminKey) => checkRepoConfig(repoId, adminKey)); + if (!isCurrentModal(token)) { + return; + } + if (checks) { + repoModalChecks[repoId] = { status: "done", writeAccess: checks.write_access, webhook: checks.webhook }; + } + renderRepoConfigModal(); } async function handleRepoConfigDelete(repo) { @@ -1533,6 +1605,39 @@ async function withAdminKey(action) { } } +/** + * Wie withAdminKey(), gibt bei Erfolg aber den Rueckgabewert von action() zurueck statt nur + * true - fuer Aufrufer, die (anders als Speichern/Loeschen, die den Zustand ohnehin per + * loadRepoConfigModal() neu laden) das Ergebnis selbst brauchen (Issue #53: Konfig-Pruefung). + * + * @template T + * @param {(adminKey: string) => Promise} action + * @returns {Promise} + */ +async function withAdminKeyResult(action) { + let adminKey = getStoredAdminKey(); + if (!adminKey) { + adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || ""; + if (!adminKey) { + return undefined; + } + } + + try { + const value = await action(adminKey); + setStoredAdminKey(adminKey); + return value; + } catch (err) { + if (err && err.isAuthError) { + setStoredAdminKey(""); + repoModalError = "Admin-Key ungültig."; + } else { + repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen."; + } + return undefined; + } +} + // --------------------------------------------------------------------------------------- // Datenbeschaffung + Orchestrierung // ---------------------------------------------------------------------------------------