$data @return array */ 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 $changes @return array */ 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|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|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> */ 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 $data * @return array */ 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 $data * @return array */ 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)); } } }