PRs werden beim Sync als Tickets (is_pull_request) angelegt, mit Projektfarbe, schwarzen Querstreifen und PR-Nummer; im Spalten-Modal mit Link auf den PR. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
11 KiB
PHP
294 lines
11 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]);
|
|
}
|
|
|
|
return $this->update((int) $existing['id'], [
|
|
'title' => $data['title'],
|
|
'description' => $data['description'] ?? null,
|
|
'color' => $data['color'] ?? null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
}
|
|
}
|