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:
fds2610 2026-08-20 07:19:22 +02:00
commit 69bf57da71
14 changed files with 615 additions and 12 deletions

View file

@ -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
View file

@ -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

View file

@ -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');

View file

@ -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']),
]);
}
}

View 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);
}
}

View file

@ -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;
}

View file

@ -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);

View 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 . ').';
}
}

View file

@ -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']
);
}
}

View file

@ -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);
}
}
}
}

View 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 ?? [];
}
};
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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
// ---------------------------------------------------------------------------------------