monitor/api/src/Repository/RepoConfigRepository.php
fds2610-bot 076dca2aef Tickets eines entfernten Projekts aus dem Kanban entfernen (Issue #43)
Bislang hat RepoConfigController::delete() nur die Projektkonfiguration
geloescht - die Tickets des Projekts blieben (nur ohne Projektfarbe,
Issue #34) dauerhaft im Kanban sichtbar. Jetzt loescht der Endpunkt
zusaetzlich alle Tickets, deren repo-Feld auf das entfernte Projekt
zeigt.
2026-08-09 20:14:14 +02:00

156 lines
5.3 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);
}
/**
* @return array<string, mixed> - der geloeschte Eintrag, damit der Aufrufer (Issue #43:
* RepoConfigController::delete() raeumt anschliessend die Tickets des Projekts auf) dessen
* owner/repo kennt, ohne ihn selbst erneut nachzuschlagen.
*/
public function delete(int $id): array
{
$repo = $this->find($id);
if ($repo === null) {
throw new NotFoundException('Projekt nicht gefunden: ' . $id);
}
$stmt = $this->db->pdo()->prepare('DELETE FROM repo_configs WHERE id = :id');
$stmt->execute(['id' => $id]);
return $repo;
}
/**
* 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'];
}
}