monitor/api/src/Repository/TicketRepository.php
fds2610-bot 65f6bc6c2e Update-Event des Monitorings und PR-Flag bestehender Tickets
update.sh meldet nach dem Neustart ein Event (POST /api/v1/events/system, Admin-Key).
upsertPullRequest markiert bereits vorhandene Tickets als Pull Request, damit sie den
/pulls/-Link bekommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 12:48:10 +02:00

304 lines
12 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). "fertig"
* ist fuer ein zukuenftiges Label reserviert und bleibt rein manuell.
*
* @var string[]
*/
private const SYNC_MANAGED_COLUMNS = ['backlog', 'bereit', 'in_arbeit', 'qa', '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, is_pull_request, created_at, updated_at)
VALUES (:external_ref, :repo, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :is_pull_request, :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',
'is_pull_request' => empty($data['is_pull_request']) ? 0 : 1,
'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. 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,
]);
}
/**
* Legt das Ticket zu einem offenen Pull Request in der Spalte "fertig" an bzw. haelt Titel
* und Beschreibung aktuell. Die Spalte bleibt bei einem bestehenden Ticket unberuehrt.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function upsertPullRequest(array $data): array
{
$existing = $this->findByExternalRef((string) $data['external_ref'], $data['repo'] ?? null);
if ($existing === null) {
return $this->create($data + ['column' => 'fertig', 'source' => 'codeberg', 'is_pull_request' => 1]);
}
$updated = $this->update((int) $existing['id'], [
'title' => $data['title'],
'description' => $data['description'] ?? null,
'color' => $data['color'] ?? null,
]);
// Ein Ticket, das vor der PR-Darstellung (z. B. durch einen aelteren Sync) als Issue
// angelegt wurde, behielte sonst is_pull_request = 0 und damit den /issues/-Link.
if (empty($existing['is_pull_request'])) {
$this->db->pdo()->prepare('UPDATE tickets SET is_pull_request = 1 WHERE id = :id')
->execute(['id' => $existing['id']]);
$updated['is_pull_request'] = 1;
}
return $updated;
}
/**
* Entfernt PR-Tickets eines Projekts, deren PR nicht mehr offen ist (gemerged/geschlossen).
*
* @param string[] $openExternalRefs
*/
public function deleteClosedPullRequests(string $repo, array $openExternalRefs): void
{
$sql = 'DELETE FROM tickets WHERE repo = ? AND is_pull_request = 1';
if ($openExternalRefs !== []) {
$sql .= ' AND external_ref NOT IN (' . implode(', ', array_fill(0, count($openExternalRefs), '?')) . ')';
}
$this->db->pdo()->prepare($sql)->execute([$repo, ...$openExternalRefs]);
}
/**
* Loescht die Projektfarbe von Tickets, deren Quell-Projekt nicht (mehr) in $slugs steht
* (Issue #34: nach Entfernen/Umbenennen eines Projekts im Projekte-Modal blieben dessen
* Tickets dauerhaft in dessen alter Farbe eingefaerbt, weil CodebergSyncService sie ab
* dann gar nicht mehr anfasst). Tickets ohne Projektbezug (repo = NULL, z.B. per API
* angelegt) bleiben unberuehrt.
*
* @param string[] $slugs - aktuell konfigurierte "owner/repo"-Werte.
*/
public function clearColorForReposNotIn(array $slugs): void
{
if ($slugs === []) {
$this->db->pdo()->exec(
"UPDATE tickets SET color = NULL WHERE repo IS NOT NULL AND color IS NOT NULL"
);
return;
}
$placeholders = implode(', ', array_fill(0, count($slugs), '?'));
$stmt = $this->db->pdo()->prepare(
"UPDATE tickets SET color = NULL
WHERE repo IS NOT NULL AND color IS NOT NULL AND repo NOT IN ($placeholders)"
);
$stmt->execute($slugs);
}
/**
* Entfernt alle Tickets eines Projekts vollstaendig aus dem Kanban (Issue #43: bislang
* blieben Tickets eines ueber das Projekte-Modal geloeschten Projekts sichtbar, nur die
* Farbe wurde ueber clearColorForReposNotIn() geloescht). Wird von
* RepoConfigController::delete() direkt nach dem Entfernen des repo_configs-Eintrags
* aufgerufen, nicht erst beim naechsten periodischen Sync.
*/
public function deleteByRepo(string $repo): void
{
$stmt = $this->db->pdo()->prepare('DELETE FROM tickets WHERE repo = :repo');
$stmt->execute(['repo' => $repo]);
}
/**
* Entfernt Tickets eines Projekts, deren external_ref beim letzten Neueinlesen nicht mehr
* gesehen wurde (Issue #51: manueller "Board neu einlesen"-Knopf, CodebergSyncService::
* resync()). Anders als der periodische Sync (reines Upsert, ruehrt bei Codeberg
* verschwundene Issues nie an, siehe upsertFromCodeberg()) bereinigt dieser Aufruf gezielt
* genau solche Karteileichen, selbst wenn sie manuell auf eine nicht vom Sync verwaltete
* Spalte (z.B. "fertig") bewegt wurden. Tickets ohne Projektbezug (repo = NULL, z.B. per
* API angelegt) bleiben unberuehrt, weil dieser Aufruf nur je konfiguriertem Projekt
* erfolgt.
*
* @param string[] $keepExternalRefs - external_refs, die beim aktuellen Lauf tatsaechlich
* bei Codeberg gesehen wurden.
*/
public function deleteOrphansForRepo(string $repo, array $keepExternalRefs): void
{
if ($keepExternalRefs === []) {
$this->deleteByRepo($repo);
return;
}
$placeholders = implode(', ', array_fill(0, count($keepExternalRefs), '?'));
$stmt = $this->db->pdo()->prepare(
"DELETE FROM tickets WHERE repo = ? AND external_ref NOT IN ($placeholders)"
);
$stmt->execute([$repo, ...$keepExternalRefs]);
}
/**
* 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));
}
}
}