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
148 lines
5 KiB
PHP
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'];
|
|
}
|
|
}
|