monitor/api/src/Repository/RepoConfigRepository.php
fds2610-bot d72c945a61 Codeberg-Projekte des Sync ueber ein Modal am PO konfigurierbar machen
Klick auf den PO oeffnet ein Modal, in dem sich die Projekte (Owner/Repo +
Farbe), aus denen der Codeberg-Sync liest, hinzufuegen, bearbeiten und
loeschen lassen - Icon-Schaltflaechen mit Tooltip statt Textbuttons. Die
Projektliste war bisher eine Umgebungsvariable (CODEBERG_REPOS, Issue #26)
und wandert dafuer in eine neue Tabelle repo_configs (Migration
003_repo_configs.sql, geseedet mit den bisherigen drei Default-Projekten);
CodebergSyncService liest sie ueber RepoConfigRepository bei jedem Lauf
frisch ein, damit eine Aenderung im laufenden --watch-Prozess ohne Neustart
wirkt. Neue Endpunkte GET/POST/PATCH/DELETE /api/v1/repo-configs, Schreiben
hinter X-Admin-Key wie die Agenten-Registrierung; das Frontend fragt den Key
bei der ersten Aenderung per Prompt ab und merkt ihn sich im lokalen
Speicher. office-modal.js bekommt dafuer einen Oeffnen-Token
(isCurrentModal()), damit ein verspaetet aufloesender Request nach dem
Oeffnen eines anderen Modals nicht dessen Inhalt ueberschreibt.

Schliesst #27
2026-07-30 17:53:39 +02:00

148 lines
5 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
/**
* Verwaltet die Codeberg-Projekte, aus denen CodebergSyncService liest (Issue #27: vorher
* CODEBERG_REPOS-Umgebungsvariable, siehe 003_repo_configs.sql). Der Sync-Dauerlaeufer ruft
* all() bei jedem Lauf frisch ab, damit eine Aenderung ueber das Frontend-Modal ohne
* Neustart des sync-Containers wirkt.
*/
final class RepoConfigRepository
{
public function __construct(private readonly Database $db)
{
}
/** @return array<int, array{owner: string, repo: string, color: string}> */
public function all(): array
{
$stmt = $this->db->pdo()->query('SELECT * FROM repo_configs ORDER BY sort_order ASC, id ASC');
return $stmt->fetchAll();
}
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM repo_configs WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function create(array $data): array
{
[$owner, $repo, $color] = $this->validate($data, null);
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO repo_configs (owner, repo, color, sort_order, created_at, updated_at)
VALUES (:owner, :repo, :color, :sort_order, :created_at, :updated_at)'
);
$stmt->execute([
'owner' => $owner,
'repo' => $repo,
'color' => $color,
'sort_order' => $this->nextSortOrder(),
'created_at' => $now,
'updated_at' => $now,
]);
return $this->find((int) $this->db->pdo()->lastInsertId());
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function update(int $id, array $data): array
{
if ($this->find($id) === null) {
throw new NotFoundException('Projekt nicht gefunden: ' . $id);
}
[$owner, $repo, $color] = $this->validate($data, $id);
$stmt = $this->db->pdo()->prepare(
'UPDATE repo_configs SET owner = :owner, repo = :repo, color = :color, updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute([
'id' => $id,
'owner' => $owner,
'repo' => $repo,
'color' => $color,
'updated_at' => $this->db->now(),
]);
return $this->find($id);
}
public function delete(int $id): void
{
if ($this->find($id) === null) {
throw new NotFoundException('Projekt nicht gefunden: ' . $id);
}
$stmt = $this->db->pdo()->prepare('DELETE FROM repo_configs WHERE id = :id');
$stmt->execute(['id' => $id]);
}
/**
* Prueft Pflichtfelder + Farbformat vorab, statt eine Verletzung der UNIQUE-Constraint
* (owner, repo) als PDOException auflaufen zu lassen (Konvention wie
* AgentRepository::updateActivity() fuer das Fremdschluessel-Pendant bei Tickets).
*
* @param array<string, mixed> $data
* @param int|null $excludeId - beim Update die eigene Zeile von der Duplikatspruefung ausnehmen.
* @return array{0: string, 1: string, 2: string}
*/
private function validate(array $data, ?int $excludeId): array
{
$owner = trim((string) ($data['owner'] ?? ''));
$repo = trim((string) ($data['repo'] ?? ''));
$color = $data['color'] ?? null;
if ($owner === '' || $repo === '') {
throw new InvalidArgumentException('Felder "owner" und "repo" sind erforderlich.');
}
if (!is_string($color) || !preg_match('/^#?[0-9a-fA-F]{6}$/', $color)) {
throw new InvalidArgumentException('Feld "color" muss ein 6-stelliger Hex-Farbwert sein (z. B. "#fbca04").');
}
if ($this->existsWithOwnerRepo($owner, $repo, $excludeId)) {
throw new InvalidArgumentException(sprintf('Projekt "%s/%s" ist bereits konfiguriert.', $owner, $repo));
}
return [$owner, $repo, '#' . ltrim($color, '#')];
}
private function existsWithOwnerRepo(string $owner, string $repo, ?int $excludeId): bool
{
$sql = 'SELECT COUNT(*) FROM repo_configs WHERE owner = :owner AND repo = :repo';
$params = ['owner' => $owner, 'repo' => $repo];
if ($excludeId !== null) {
$sql .= ' AND id != :exclude_id';
$params['exclude_id'] = $excludeId;
}
$stmt = $this->db->pdo()->prepare($sql);
$stmt->execute($params);
return ((int) $stmt->fetchColumn()) > 0;
}
private function nextSortOrder(): int
{
$row = $this->db->pdo()->query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM repo_configs')->fetch();
return (int) $row['next'];
}
}