monitor/api/src/Repository/TicketRepository.php
fds2610-bot 3aaf6aea54 Tickets aus mehreren Codeberg-Projekten anzeigen (monitor/Steuerkram/nc-gantt)
Der Codeberg-Sync liest nun Issues aus mehreren konfigurierbaren Projekten
(Config::codebergRepos(), Default: fds2610/monitor gelb, fds2610/Steuerkram
gruen, fds2610/nc-gantt blau) statt nur aus einem einzigen Repo. Damit
external_ref (Issue-Nummer) projektuebergreifend nicht kollidiert, ist es
jetzt zusammen mit dem neuen Ticket-Feld "repo" eindeutig; Migration
002_multi_repo_tickets.sql ergaenzt die Spalte und den Index. Da Migrationen
dadurch nicht mehr rein additiv sind, trackt Database::migrate() jetzt
angewandte Dateien in schema_migrations, damit ALTER TABLE nicht bei jedem
Prozessstart erneut laeuft. Das Frontend baut Codeberg-Links jetzt aus dem
repo-Feld jedes Tickets statt aus einer global fixen owner/repo-Angabe.

Schliesst #26
2026-07-30 17:34:49 +02:00

190 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
final class TicketRepository
{
/** @var string[] */
public const COLUMNS = ['backlog', 'bereit', 'in_arbeit', 'qa', 'testing', 'fertig', 'done'];
/**
* Spalten, die der Codeberg-Sync anhand von Issue-Status/Labels pflegt (F14). "qa" und
* "fertig" sind fuer zukuenftige Labels reserviert und bleiben rein manuell.
*
* @var string[]
*/
private const SYNC_MANAGED_COLUMNS = ['backlog', 'bereit', 'in_arbeit', 'testing', 'done'];
public function __construct(private readonly Database $db)
{
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function create(array $data): array
{
$column = $data['column'] ?? 'backlog';
$this->assertValidColumn($column);
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO tickets (external_ref, repo, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at)
VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)'
);
$stmt->execute([
'external_ref' => $data['external_ref'] ?? null,
'repo' => $data['repo'] ?? null,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'column_name' => $column,
'type' => $data['type'] ?? null,
'color' => $data['color'] ?? null,
'assignee_agent_id' => $data['assignee_agent_id'] ?? null,
'source' => $data['source'] ?? 'api',
'created_at' => $now,
'updated_at' => $now,
]);
return $this->find((int) $this->db->pdo()->lastInsertId());
}
/** @param array<string, mixed> $changes @return array<string, mixed> */
public function update(int $id, array $changes): array
{
if ($this->find($id) === null) {
throw new NotFoundException('Ticket nicht gefunden: ' . $id);
}
$fields = [];
$params = ['id' => $id];
if (array_key_exists('column', $changes)) {
$this->assertValidColumn($changes['column']);
$fields[] = 'column_name = :column_name';
$params['column_name'] = $changes['column'];
}
foreach (['title', 'description', 'type', 'color'] as $key) {
if (array_key_exists($key, $changes)) {
$fields[] = "$key = :$key";
$params[$key] = $changes[$key];
}
}
if (array_key_exists('assignee_agent_id', $changes)) {
$fields[] = 'assignee_agent_id = :assignee_agent_id';
$params['assignee_agent_id'] = $changes['assignee_agent_id'];
}
if ($fields === []) {
return $this->find($id);
}
$fields[] = 'updated_at = :updated_at';
$params['updated_at'] = $this->db->now();
$sql = 'UPDATE tickets SET ' . implode(', ', $fields) . ' WHERE id = :id';
$this->db->pdo()->prepare($sql)->execute($params);
return $this->find($id);
}
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/**
* external_ref (Issue-Nummer) ist nur je Repo eindeutig (F26: mehrere Codeberg-Projekte),
* daher zusaetzlich ueber "repo" ("owner/name") gescoped. $repo bleibt optional/null fuer
* Tickets ohne Codeberg-Projektbezug.
*
* @return array<string, mixed>|null
*/
public function findByExternalRef(string $externalRef, ?string $repo = null): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref AND repo IS :repo');
$stmt->execute(['ref' => $externalRef, 'repo' => $repo]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/**
* Liefert Tickets sortiert nach Spalte und Alter, damit das Frontend die "aelteste 4 +
* Stapel"-Regel (F4) ohne weitere API-Unterstuetzung umsetzen kann.
*
* @return array<int, array<string, mixed>>
*/
public function all(?string $column = null): array
{
if ($column !== null) {
$this->assertValidColumn($column);
$stmt = $this->db->pdo()->prepare(
'SELECT * FROM tickets WHERE column_name = :column ORDER BY created_at ASC, id ASC'
);
$stmt->execute(['column' => $column]);
return $stmt->fetchAll();
}
$stmt = $this->db->pdo()->query('SELECT * FROM tickets ORDER BY column_name ASC, created_at ASC, id ASC');
return $stmt->fetchAll();
}
/**
* Legt ein Ticket aus einem Codeberg-Issue an oder aktualisiert es (Abgleich ueber
* external_ref). Wird vom Codeberg-Sync fuer Backlog/Done verwendet (F4).
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function upsertFromCodeberg(array $data): array
{
$existing = $this->findByExternalRef((string) $data['external_ref'], $data['repo'] ?? null);
if ($existing === null) {
return $this->create($data + ['source' => 'codeberg']);
}
// Manuell auf eine nicht vom Sync verwaltete Spalte bewegte Tickets (z. B. qa/fertig)
// werden vom periodischen Codeberg-Sync nicht zurueckgesetzt.
if (!in_array($existing['column_name'], self::SYNC_MANAGED_COLUMNS, true)) {
return $existing;
}
return $this->update((int) $existing['id'], [
'column' => $data['column'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'type' => $data['type'] ?? null,
'color' => $data['color'] ?? null,
]);
}
/**
* Billiger "Fingerabdruck" (Anzahl + juengster Aenderungszeitpunkt) fuer den SSE-Endpoint
* (Realtime\ChangeFeed, F13) - aendert sich bei jedem Anlegen/Verschieben eines Tickets.
*/
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM tickets')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
private function assertValidColumn(mixed $column): void
{
if (!is_string($column) || !in_array($column, self::COLUMNS, true)) {
throw new InvalidArgumentException('Ungueltige Spalte: ' . var_export($column, true));
}
}
}