$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, 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 $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. 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)); } } }