Manchmal werden bei Codeberg geloeschte oder anderweitig verschwundene Issues vom periodischen Sync nicht bereinigt (er upsertet nur, ruehrt fehlende Issues nie an). Neuer Knopf neben der "Kanban-Board"-Ueberschrift stoesst dafuer einen synchronen Voll-Resync an (POST /api/v1/tickets/resync, X-Admin-Key): jedes konfigurierte Projekt wird neu gelesen, Tickets zu inzwischen verschwundenen Issues werden je Projekt entfernt (auch wenn sie manuell auf eine nicht vom Sync verwaltete Spalte wie "fertig" bewegt wurden), Tickets zu weiterhin existierenden Issues bleiben unangetastet. Der PO geht dabei sichtbar ans Board und steht dort, bis die Anfrage abgeschlossen ist - anders als beim automatischen Board-Besuch (feste Animationsdauer je Stage) macht das die tatsaechliche Sync-Dauer sichtbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
9.7 KiB
PHP
258 lines
9.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). "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, 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. 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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
}
|
|
}
|