Merge pull request 'Projekt-Konfig-Pruefung: Schreibrechte + Webhook-Status im Modal anzeigen' (#54) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/54
This commit is contained in:
commit
69bf57da71
14 changed files with 615 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
25
API.md
25
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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<string, string> $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']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
api/src/Sync/CodebergApiException.php
Normal file
22
api/src/Sync/CodebergApiException.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Sync;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Wie die generische RuntimeException aus CurlCodebergClient (Netzwerkfehler), traegt aber
|
||||
* zusaetzlich den HTTP-Status einer beantworteten, aber fehlerhaften Anfrage (z.B. 403 ohne
|
||||
* Admin-Rechte, 404 unbekanntes Projekt) - Issue #53: RepoHealthChecker muss zwischen diesen
|
||||
* Faellen unterscheiden, um dem Nutzer eine konkrete Fehlermeldung im Tooltip zu zeigen statt
|
||||
* nur "Pruefung fehlgeschlagen".
|
||||
*/
|
||||
final class CodebergApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly int $status, string $message)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,4 +13,23 @@ interface CodebergClient
|
|||
* @return array<int, array{number: int, title: string, body: ?string, labels: array<int, array{name: string, color: string}>}>
|
||||
*/
|
||||
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<int, array{active: bool, url: string, content_type: string}>
|
||||
*/
|
||||
public function listWebhooks(string $owner, string $repo): array;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,31 @@ final class CurlCodebergClient implements CodebergClient
|
|||
return $issues;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
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<int|string, mixed> */
|
||||
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);
|
||||
|
|
|
|||
102
api/src/Sync/RepoHealthChecker.php
Normal file
102
api/src/Sync/RepoHealthChecker.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Sync;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Issue #53: Wird nach dem Anlegen/Bearbeiten eines Projekts im Konfig-Dialog aufgerufen
|
||||
* (RepoConfigController::check()) und prueft mit dem CODEBERG_TOKEN des Bot-Accounts zwei
|
||||
* Voraussetzungen fuer den Sync: Schreibrechte auf das Projekt (fuer kuenftige
|
||||
* Issue-Kommentare/-Aenderungen des Bots) und einen aktiven Webhook auf diese monitor-Instanz
|
||||
* (fuer den Sofort-Sync statt nur periodisches Polling, siehe API.md
|
||||
* "Codeberg-Sync (Backlog/Done)"). Ergebnis fuettert die gruenen/roten Haken neben den
|
||||
* Bearbeiten-/Loeschen-Symbolen im Frontend.
|
||||
*/
|
||||
final class RepoHealthChecker
|
||||
{
|
||||
private const WEBHOOK_PATH = '/api/v1/webhooks/codeberg';
|
||||
|
||||
public function __construct(private readonly CodebergClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* write_access: array{ok: bool, message: string},
|
||||
* webhook: array{ok: bool, message: string}
|
||||
* }
|
||||
*/
|
||||
public function check(string $owner, string $repo): array
|
||||
{
|
||||
return [
|
||||
'write_access' => $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 . ').';
|
||||
}
|
||||
}
|
||||
|
|
@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
api/tests/Unit/Sync/RepoHealthCheckerTest.php
Normal file
130
api/tests/Unit/Sync/RepoHealthCheckerTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Tests\Sync;
|
||||
|
||||
use Monitor\Api\Sync\CodebergApiException;
|
||||
use Monitor\Api\Sync\CodebergClient;
|
||||
use Monitor\Api\Sync\RepoHealthChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class RepoHealthCheckerTest extends TestCase
|
||||
{
|
||||
public function testWriteAccessOkWhenPushPermissionTrue(): void
|
||||
{
|
||||
$client = $this->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<int, array{active: bool, url: string, content_type: string}>|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 ?? [];
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" | <id des bearbeiteten Projekts>
|
||||
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<T>} action
|
||||
* @returns {Promise<T|undefined>}
|
||||
*/
|
||||
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
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue