use crate::global_store;
use crate::turso::{self, Connection, TxGuard, Value, params_from_iter};
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write;
use std::path::Path;
use std::time::Duration as StdDuration;
use tracing::{debug, info, warn};
global_store! {
pub static BOARD: BoardStore,
constructor = BoardStore::open,
}
pub async fn run_archive_cancelled_loop() {
let interval = StdDuration::from_mins(5);
loop {
if !crate::shutdown::sleep_or_shutdown(interval).await {
break;
}
let Some(board) = BOARD.get() else {
warn!("Archive cancelled loop: board not initialized");
continue;
};
match board.archive_stale_cancelled(1).await {
Ok(n) if n > 0 => info!(count = n, "Archived stale cancelled tickets"),
Ok(_) => debug!("Archive cancelled loop: no stale tickets"),
Err(e) => warn!(error = %e, "Archive cancelled loop failed"),
}
}
}
const SCHEMA: &str = "\
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'backlog',
assigned_to TEXT,
workspace_name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
prerequisites TEXT NOT NULL DEFAULT '[]',
supersedes TEXT,
superseded_by TEXT,
commit_hash TEXT,
lines_added INTEGER,
lines_removed INTEGER,
reporter TEXT NOT NULL DEFAULT '',
is_archived INTEGER NOT NULL DEFAULT 0,
embedding BLOB,
pipeline_reservation INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS ticket_comments (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (ticket_id) REFERENCES tickets(id)
);
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket_id ON ticket_comments(ticket_id);
CREATE TABLE IF NOT EXISTS ticket_counters (
workspace_name TEXT PRIMARY KEY,
next_id INTEGER NOT NULL DEFAULT 1
);";
const TICKETS_FTS_INDEX_NAME: &str = "idx_tickets_title_fts";
const TICKETS_FTS_INDEX_DDL: &str = "\
CREATE INDEX IF NOT EXISTS idx_tickets_title_fts ON tickets \
USING fts (title) WITH (tokenizer = 'ngram')";
const TICKET_COLUMNS: &str = "id, title, description, status, assigned_to, \
workspace_name, created_at, updated_at, prerequisites, supersedes, \
superseded_by, commit_hash, lines_added, lines_removed, reporter, is_archived, \
pipeline_reservation";
const COL_TICKET_ID: usize = 0;
const COL_TICKET_TITLE: usize = 1;
const COL_TICKET_DESCRIPTION: usize = 2;
const COL_TICKET_STATUS: usize = 3;
const COL_TICKET_ASSIGNED_TO: usize = 4;
const COL_TICKET_WORKSPACE_NAME: usize = 5;
const COL_TICKET_CREATED_AT: usize = 6;
const COL_TICKET_UPDATED_AT: usize = 7;
const COL_TICKET_PREREQUISITES: usize = 8;
const COL_TICKET_SUPERSEDES: usize = 9;
const COL_TICKET_SUPERSEDED_BY: usize = 10;
const COL_TICKET_COMMIT_HASH: usize = 11;
const COL_TICKET_LINES_ADDED: usize = 12;
const COL_TICKET_LINES_REMOVED: usize = 13;
const COL_TICKET_REPORTER: usize = 14;
const COL_TICKET_IS_ARCHIVED: usize = 15;
const COL_TICKET_PIPELINE_RESERVATION: usize = 16;
const COMMENT_COLUMNS: &str = "role, content, created_at";
const COL_COMMENT_ROLE: usize = 0;
const COL_COMMENT_CONTENT: usize = 1;
const COL_COMMENT_CREATED_AT: usize = 2;
const PIPELINE_BLOCKING_STATUSES: &[TicketPhase] = &[
TicketPhase::InDevelopment,
TicketPhase::InDiagnostics,
TicketPhase::DiagnosticsDone,
TicketPhase::InReview,
TicketPhase::Reviewed,
TicketPhase::InQa,
TicketPhase::QaPassed,
];
const TRANSITORY_HANDOFF_PHASES: &[TicketPhase] = &[
TicketPhase::DiagnosticsDone,
TicketPhase::Reviewed,
TicketPhase::QaPassed,
];
pub const UNBLOCKING_STATUSES: &[TicketPhase] = &[TicketPhase::Done, TicketPhase::Cancelled];
fn status_list_sql_fragment(statuses: &[TicketPhase]) -> String {
statuses
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(", ")
}
fn parse_prereqs(raw: &str) -> Result<Vec<String>> {
serde_json::from_str(raw).with_context(|| {
if raw.len() > 200 {
format!(
"Corrupt prerequisites JSON in database: {}…",
&raw[..raw.floor_char_boundary(200)]
)
} else {
format!("Corrupt prerequisites JSON in database: {raw}")
}
})
}
pub const DEFAULT_TICKET_PHASE: TicketPhase = TicketPhase::Backlog;
#[derive(Debug, Clone, Serialize)]
pub struct TicketComment {
pub role: String,
pub content: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Ticket {
pub id: String,
pub title: String,
pub description: String,
pub status: TicketPhase,
pub assigned_to: Option<String>,
pub workspace_name: String,
pub created_at: String,
pub updated_at: String,
pub comments: Vec<TicketComment>,
pub prerequisites: Vec<String>,
pub supersedes: Option<String>,
pub superseded_by: Option<String>,
pub commit_hash: Option<String>,
pub lines_added: Option<i64>,
pub lines_removed: Option<i64>,
pub reporter: String,
pub is_archived: bool,
pub pipeline_reservation: bool,
}
impl Ticket {
#[must_use]
pub fn short_display(&self) -> String {
format!(
" [{}] [{}] {}: {}",
self.reporter, self.status, self.id, self.title
)
}
#[must_use]
pub fn detailed_display(&self) -> String {
let mut out = format!(
"Ticket: {id}\n\
Title: {title}\n\
Description: {description}\n\
Status: {status}\n\
Reporter: {reporter}\n\
Workspace: {workspace}\n\
Created: {created}\n\
Updated: {updated}\n",
id = self.id,
title = self.title,
description = self.description,
status = self.status,
reporter = self.reporter,
workspace = self.workspace_name,
created = self.created_at,
updated = self.updated_at,
);
if let Some(ref s) = self.supersedes {
let _ = writeln!(out, "Supersedes: {s}");
}
if let Some(ref s) = self.superseded_by {
let _ = writeln!(out, "Superseded by: {s}");
}
if !self.prerequisites.is_empty() {
let _ = writeln!(out, "Prerequisites: {}", self.prerequisites.join(", "));
}
if self.is_archived {
out.push_str("Archived: yes\n");
}
out.push_str(&self.format_comments());
out
}
#[must_use]
pub fn format_comments(&self) -> String {
let mut s = String::from("Comments:");
if self.comments.is_empty() {
s.push_str("\n (no comments)");
} else {
for c in &self.comments {
let end = 19.min(c.created_at.len());
let ts = &c.created_at[..end];
let _ = write!(s, "\n [{}] ({}): {}", c.role, ts, c.content);
}
}
s
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Serialize,
strum::Display,
strum::EnumString,
strum::AsRefStr,
strum::EnumIter,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum TicketPhase {
Backlog,
Analysis,
Planning,
ReadyForDevelopment,
InDevelopment,
InDiagnostics,
DiagnosticsDone,
InReview,
Reviewed,
InQa,
QaPassed,
Done,
Cancelled,
Failed,
Paused,
}
impl TicketPhase {
#[must_use]
pub fn is_transitory_handoff(&self) -> bool {
TRANSITORY_HANDOFF_PHASES.contains(self)
}
#[must_use]
pub fn is_unblocking(&self) -> bool {
UNBLOCKING_STATUSES.contains(self)
}
#[must_use]
pub fn is_pipeline_blocking(&self) -> bool {
PIPELINE_BLOCKING_STATUSES.contains(self)
}
#[must_use]
pub fn display_name(&self) -> String {
self.as_ref().replace('_', " ")
}
}
impl BoardStore {
pub async fn open(root: &Path) -> Result<Self> {
let db_path = root.join("db/board.db");
let conn = turso::open_with_schema(&db_path, SCHEMA).await?;
crate::turso::ensure_fts_index(
&conn,
TICKETS_FTS_INDEX_NAME,
"ngram",
TICKETS_FTS_INDEX_DDL,
)
.await?;
let version: i64 = conn
.query_row("PRAGMA user_version", turso::params![], |row| row.get(0))
.await
.unwrap_or(0);
if version < 2 {
let _ = conn
.execute(
"ALTER TABLE tickets ADD COLUMN pipeline_reservation \
INTEGER NOT NULL DEFAULT 0",
turso::params![],
)
.await;
conn.execute("PRAGMA user_version = 2", turso::params![])
.await
.context("Failed to set PRAGMA user_version = 2")?;
}
Ok(Self { conn })
}
#[allow(clippy::too_many_arguments)]
async fn insert_ticket_in_tx(
tx: &TxGuard<'_>,
id: &str,
title: &str,
description: &str,
workspace_name: &str,
phase: TicketPhase,
prerequisites: &[String],
supersedes: Option<&str>,
reporter: &str,
embedding: Option<&[u8]>,
) -> Result<()> {
let now = turso::now();
let prereqs_json = serde_json::to_string(prerequisites)?;
tx.execute(
"INSERT INTO tickets (id, title, description, status, workspace_name, \
created_at, updated_at, prerequisites, supersedes, reporter, embedding) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
turso::params![
id,
title,
description,
phase.as_ref(),
workspace_name,
now.as_str(),
now.as_str(),
prereqs_json.as_str(),
supersedes,
reporter,
embedding,
],
)
.await?;
Ok(())
}
async fn rewire_dependents(
tx: &TxGuard<'_>,
old_id: &str,
new_id: &str,
workspace_name: &str,
) -> Result<()> {
let dep_rows = tx
.query(
"SELECT DISTINCT t.id, t.prerequisites \
FROM tickets t, json_each(t.prerequisites) AS je \
WHERE je.value = ?1 AND t.workspace_name = ?2",
turso::params![old_id, workspace_name],
)
.await?;
for row in &dep_rows {
let dep_id: String = row.get(0)?;
let raw: String = row.get(1)?;
let mut prereqs: Vec<String> = parse_prereqs(&raw)
.with_context(|| format!("Failed to parse prerequisites for ticket {dep_id}"))?;
let mut changed = false;
for p in &mut prereqs {
if *p == old_id {
*p = new_id.to_string();
changed = true;
}
}
if changed {
let new_json = serde_json::to_string(&prereqs)?;
tx.execute(
"UPDATE tickets SET prerequisites = ?1 WHERE id = ?2",
turso::params![new_json, dep_id],
)
.await?;
}
}
Ok(())
}
async fn begin_tx_and_validate_prerequisites(
&self,
workspace_name: &str,
prerequisites: &[String],
) -> Result<(TxGuard<'_>, String)> {
let tx = self.conn.begin_tx().await?;
let seq: i64 = tx
.query_row(
"INSERT INTO ticket_counters (workspace_name, next_id) VALUES (?1, 1) \
ON CONFLICT(workspace_name) DO UPDATE SET next_id = ticket_counters.next_id + 1 \
RETURNING next_id - 1",
turso::params![workspace_name],
|row| row.get(0),
)
.await?;
let id = format!("{workspace_name}-{seq}");
anyhow::ensure!(
!prerequisites.contains(&id),
"Ticket cannot depend on itself: {id}"
);
self.validate_prerequisites(&tx, prerequisites, workspace_name)
.await?;
Ok((tx, id))
}
#[allow(clippy::too_many_arguments)]
pub async fn create_ticket(
&self,
title: &str,
description: &str,
ws: &crate::Workspace,
phase: TicketPhase,
prerequisites: &[String],
reporter: &str,
embedding: Option<&[u8]>,
) -> Result<String> {
let (tx, id) = self
.begin_tx_and_validate_prerequisites(&ws.name, prerequisites)
.await?;
Self::insert_ticket_in_tx(
&tx,
&id,
title,
description,
&ws.name,
phase,
prerequisites,
None,
reporter,
embedding,
)
.await?;
tx.commit().await?;
Ok(id)
}
#[allow(clippy::too_many_arguments)]
pub async fn supersede_and_create(
&self,
supersede_id: &str,
title: &str,
description: &str,
ws: &crate::Workspace,
prerequisites: &[String],
reporter: &str,
embedding: Option<&[u8]>,
) -> Result<String> {
anyhow::ensure!(
!prerequisites.iter().any(|p| p == supersede_id),
"Ticket cannot supersede and depend on the same ticket: {supersede_id}"
);
let (tx, new_id) = self
.begin_tx_and_validate_prerequisites(&ws.name, prerequisites)
.await?;
let rows = tx
.query(
"SELECT workspace_name, status FROM tickets WHERE id = ?1",
turso::params![supersede_id],
)
.await?;
let row = rows
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Superseded ticket not found: {supersede_id}"))?;
let old_ws: String = row.get(0)?;
anyhow::ensure!(
old_ws == ws.name,
"Superseded ticket {supersede_id} belongs to workspace '{old_ws}', \
not the current workspace '{}'. \
Cross-workspace supersede is not allowed.",
ws.name,
);
let status_str: String = row.get(1)?;
let old_status: TicketPhase = status_str.parse()?;
let now = turso::now();
let cancelled_rows = tx
.execute(
"UPDATE tickets SET status = ?1, updated_at = ?2, assigned_to = NULL, \
superseded_by = ?4, is_archived = 1 WHERE id = ?3",
turso::params![
TicketPhase::Cancelled.as_ref(),
now,
supersede_id,
new_id.as_str(),
],
)
.await?;
Self::ensure_ticket_found(cancelled_rows, supersede_id, "cancel superseded ticket")?;
Self::insert_ticket_in_tx(
&tx,
&new_id,
title,
description,
&ws.name,
TicketPhase::Backlog,
prerequisites,
Some(supersede_id),
reporter,
embedding,
)
.await?;
Self::rewire_dependents(&tx, supersede_id, &new_id, &ws.name).await?;
tx.commit().await?;
crate::ticket_buffer::push(
&ws.name,
supersede_id,
old_status.as_ref(),
TicketPhase::Cancelled.as_ref(),
);
crate::registry::AGENT_REGISTRY.cancel_by_ticket_id(supersede_id);
Ok(new_id)
}
async fn ticket_from_row(&self, row: &turso::Row, load_comments: bool) -> Result<Ticket> {
let id: String = row.get(COL_TICKET_ID)?;
let comments = if load_comments {
self.get_comments(&id).await?
} else {
Vec::new()
};
let prerequisites_raw: String = row.get(COL_TICKET_PREREQUISITES)?;
let prerequisites = parse_prereqs(&prerequisites_raw)
.with_context(|| format!("Failed to parse prerequisites for ticket {id}"))?;
Ok(Ticket {
id,
title: row.get(COL_TICKET_TITLE)?,
description: row.get(COL_TICKET_DESCRIPTION)?,
status: row
.get::<String>(COL_TICKET_STATUS)?
.parse::<TicketPhase>()?,
assigned_to: row.get(COL_TICKET_ASSIGNED_TO)?,
workspace_name: row.get(COL_TICKET_WORKSPACE_NAME)?,
created_at: row.get(COL_TICKET_CREATED_AT)?,
updated_at: row.get(COL_TICKET_UPDATED_AT)?,
comments,
prerequisites,
supersedes: row.get(COL_TICKET_SUPERSEDES)?,
superseded_by: row.get(COL_TICKET_SUPERSEDED_BY)?,
commit_hash: row.get(COL_TICKET_COMMIT_HASH)?,
lines_added: row.get(COL_TICKET_LINES_ADDED)?,
lines_removed: row.get(COL_TICKET_LINES_REMOVED)?,
reporter: row.get::<String>(COL_TICKET_REPORTER)?,
is_archived: row.get::<i64>(COL_TICKET_IS_ARCHIVED)? != 0,
pipeline_reservation: row.get::<i64>(COL_TICKET_PIPELINE_RESERVATION)? != 0,
})
}
pub(crate) async fn claim_ticket_in_workspace(
&self,
expected_phase: TicketPhase,
target_phase: TicketPhase,
workspace_name: &str,
require_clear_pipeline: bool,
) -> Result<Option<Ticket>> {
let now = turso::now();
let prereq_filter = format!(
"AND NOT EXISTS ( \
SELECT 1 FROM json_each(t1.prerequisites) AS je \
JOIN tickets t_pre ON t_pre.id = je.value \
WHERE t_pre.status NOT IN ({}) \
)",
status_list_sql_fragment(UNBLOCKING_STATUSES),
);
let pipeline_blocker_clause = if require_clear_pipeline {
let blocker_sql = status_list_sql_fragment(PIPELINE_BLOCKING_STATUSES);
format!(
"AND NOT EXISTS (SELECT 1 FROM tickets t2 \
WHERE t2.workspace_name = t1.workspace_name \
AND t2.status IN ({blocker_sql}) \
AND t2.id != t1.id) "
)
} else {
String::new()
};
let sql = format!(
"UPDATE tickets SET status = ?1, assigned_to = NULL, updated_at = ?2, \
pipeline_reservation = 0 \
WHERE id = (SELECT t1.id FROM tickets t1 \
WHERE t1.status = ?3 AND t1.assigned_to IS NULL AND t1.workspace_name = ?4 \
{pipeline_blocker_clause}{prereq_filter} \
ORDER BY t1.pipeline_reservation DESC, t1.created_at ASC LIMIT 1) \
RETURNING {TICKET_COLUMNS}"
);
let rows = self
.conn
.query(
&sql,
turso::params![
target_phase.as_ref(),
now,
expected_phase.as_ref(),
workspace_name,
],
)
.await?;
match rows.into_iter().next() {
Some(row) => Ok(Some(self.ticket_from_row(&row, true).await?)),
None => Ok(None),
}
}
pub async fn get_ticket(&self, id: &str) -> Result<Option<Ticket>> {
let sql = format!("SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?1");
let rows = self.conn.query(&sql, turso::params![id]).await?;
match rows.into_iter().next() {
Some(row) => Ok(Some(self.ticket_from_row(&row, true).await?)),
None => Ok(None),
}
}
pub async fn get_ticket_status(&self, id: &str) -> Result<Option<TicketPhase>> {
let sql = "SELECT status FROM tickets WHERE id = ?1";
let rows = self.conn.query(sql, turso::params![id]).await?;
match rows.into_iter().next() {
Some(row) => {
let status: String = row.get(0)?;
Ok(Some(status.parse()?))
}
None => Ok(None),
}
}
pub(crate) async fn list_tickets_in_phase(
&self,
phase: TicketPhase,
workspace_name: &str,
) -> Result<Vec<Ticket>> {
self.list_all_tickets(Some(workspace_name), Some(phase))
.await
}
pub async fn transition_to(
&self,
id: &str,
expected_phase: Option<TicketPhase>,
target_phase: TicketPhase,
) -> Result<()> {
self.transition_to_inner(id, expected_phase, target_phase, None)
.await
}
async fn transition_to_inner(
&self,
id: &str,
expected_phase: Option<TicketPhase>,
target_phase: TicketPhase,
reservation: Option<bool>,
) -> Result<()> {
let now = turso::now();
let guard: Option<&str> = expected_phase.as_ref().map(TicketPhase::as_ref);
let action = match reservation {
Some(v) => format!(
"set status to {} (reservation={})",
target_phase.as_ref(),
v,
),
None => format!("set status to {}", target_phase.as_ref()),
};
self.execute_and_cancel(
"UPDATE tickets SET status = ?1, assigned_to = NULL, updated_at = ?2, \
pipeline_reservation = COALESCE(?5, pipeline_reservation) \
WHERE id = ?3 AND (?4 IS NULL OR status = ?4)",
turso::params![target_phase.as_ref(), now, id, guard, reservation],
id,
&action,
)
.await
}
fn ensure_ticket_found(rows: u64, id: &str, action: &str) -> Result<()> {
anyhow::ensure!(rows > 0, "Ticket {id} not found — cannot {action}");
Ok(())
}
async fn execute_and_cancel(
&self,
sql: &str,
params: impl turso::IntoParams + Send + 'static,
id: &str,
action: &str,
) -> Result<()> {
let rows = self.conn.execute(sql, params).await?;
Self::ensure_ticket_found(rows, id, action)?;
crate::registry::AGENT_REGISTRY.cancel_by_ticket_id(id);
Ok(())
}
pub async fn set_assigned_to(&self, id: &str, assigned_to: Option<&str>) -> Result<()> {
let now = turso::now();
let action = if assigned_to.is_some() {
"set assigned_to"
} else {
"clear assigned_to"
};
self.execute_and_cancel(
"UPDATE tickets SET assigned_to = ?1, updated_at = ?2 WHERE id = ?3",
turso::params![assigned_to, now, id],
id,
action,
)
.await
}
pub async fn claim_diagnostics(&self, id: &str) -> Result<bool> {
let now = turso::now();
let rows = self
.conn
.execute(
"UPDATE tickets \
SET assigned_to = 'diagnostics', updated_at = ?1 \
WHERE id = ?2 \
AND assigned_to IS NULL \
AND status = ?3",
turso::params![now, id, TicketPhase::InDiagnostics.as_ref()],
)
.await?;
if rows > 0 {
crate::registry::AGENT_REGISTRY.cancel_by_ticket_id(id);
}
Ok(rows > 0)
}
pub async fn set_commit_info(
&self,
id: &str,
hash: &str,
lines_added: i64,
lines_removed: i64,
) -> Result<()> {
debug_assert!(
lines_added >= 0,
"lines_added must be non-negative: {lines_added}"
);
debug_assert!(
lines_removed >= 0,
"lines_removed must be non-negative: {lines_removed}"
);
let now = turso::now();
let rows = self
.conn
.execute(
"UPDATE tickets SET commit_hash = ?1, lines_added = ?2, lines_removed = ?3, \
updated_at = ?4 WHERE id = ?5",
turso::params![hash, lines_added, lines_removed, now, id],
)
.await?;
Self::ensure_ticket_found(rows, id, "set commit info")?;
Ok(())
}
pub async fn transition_to_with_reservation(
&self,
id: &str,
expected_phase: Option<TicketPhase>,
target_phase: TicketPhase,
reservation: bool,
) -> Result<()> {
self.transition_to_inner(id, expected_phase, target_phase, Some(reservation))
.await
}
const RESET_TRANSITIONS: &[(TicketPhase, TicketPhase, bool)] = &[
(
TicketPhase::InDevelopment,
TicketPhase::ReadyForDevelopment,
true,
),
(
TicketPhase::InDiagnostics,
TicketPhase::ReadyForDevelopment,
true,
),
(TicketPhase::InQa, TicketPhase::Reviewed, false),
(TicketPhase::InReview, TicketPhase::DiagnosticsDone, false),
(TicketPhase::Analysis, TicketPhase::Backlog, false),
];
pub async fn reset_inflight_tickets(&self) -> Result<()> {
let now = turso::now();
for (from, to, reserve) in Self::RESET_TRANSITIONS {
self.conn
.execute(
"UPDATE tickets SET status = ?1, assigned_to = NULL, updated_at = ?2, \
pipeline_reservation = ?4 WHERE status = ?3",
turso::params![to.as_ref(), now.clone(), from.as_ref(), i64::from(*reserve)],
)
.await?;
}
Ok(())
}
pub async fn has_pipeline_blocker_for_workspace(&self, workspace_name: &str) -> Result<bool> {
let blocker_sql = status_list_sql_fragment(PIPELINE_BLOCKING_STATUSES);
let sql = format!(
"SELECT 1 FROM tickets WHERE \
(status IN ({blocker_sql}) OR \
(status = '{}' AND pipeline_reservation = 1)) \
AND workspace_name = ?1 AND is_archived = 0 LIMIT 1",
TicketPhase::ReadyForDevelopment.as_ref()
);
let rows = self
.conn
.query(&sql, turso::params![workspace_name])
.await?;
Ok(!rows.is_empty())
}
pub async fn add_comment(&self, id: &str, role: &str, content: &str) -> Result<()> {
let comment_id = crate::generate_id();
let now = turso::now();
let tx = self.conn.begin_tx().await?;
tx.execute(
"INSERT INTO ticket_comments (id, ticket_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
turso::params![comment_id, id, role, content, now.clone()],
)
.await?;
tx.execute(
"UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
turso::params![now, id],
)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn get_comments(&self, id: &str) -> Result<Vec<TicketComment>> {
let sql = format!(
"SELECT {COMMENT_COLUMNS} FROM ticket_comments WHERE ticket_id = ?1 ORDER BY created_at ASC"
);
let rows = self.conn.query(&sql, turso::params![id]).await?;
let mut comments = Vec::new();
for row in rows {
comments.push(TicketComment {
role: row.get(COL_COMMENT_ROLE)?,
content: row.get(COL_COMMENT_CONTENT)?,
created_at: row.get(COL_COMMENT_CREATED_AT)?,
});
}
Ok(comments)
}
async fn validate_prerequisites(
&self,
tx: &TxGuard<'_>,
prerequisite_ids: &[String],
workspace_name: &str,
) -> Result<()> {
if prerequisite_ids.is_empty() {
return Ok(());
}
let sql = format!(
"SELECT id, workspace_name FROM tickets WHERE id IN ({})",
turso::sql_in_placeholders(prerequisite_ids.len()),
);
let params: Vec<Value> = prerequisite_ids
.iter()
.map(|id| Value::Text(id.clone()))
.collect();
let rows = tx.query(&sql, params_from_iter(params)).await?;
let mut found: Vec<(String, String)> = Vec::new();
for row in rows {
let id: String = row.get(0)?;
let ws_name: String = row.get(1)?;
found.push((id, ws_name));
}
for pid in prerequisite_ids {
let ws_name = found
.iter()
.find(|(i, _)| i == pid)
.map(|(_, ws)| ws)
.ok_or_else(|| anyhow::anyhow!("Prerequisite ticket not found: {pid}"))?;
anyhow::ensure!(
ws_name == workspace_name,
"Prerequisite {pid} belongs to workspace '{ws_name}', \
not the ticket's workspace '{workspace_name}'. \
Cross-workspace prerequisites are not allowed.",
);
}
Ok(())
}
pub async fn list_all_tickets(
&self,
workspace_name: Option<&str>,
status_filter: Option<TicketPhase>,
) -> Result<Vec<Ticket>> {
let sql = format!(
"SELECT {TICKET_COLUMNS} FROM tickets \
WHERE (?1 IS NULL OR workspace_name = ?1) \
AND (?2 IS NULL OR status = ?2) \
AND is_archived = 0 \
ORDER BY created_at DESC"
);
let status_str: Option<&str> = status_filter.as_ref().map(TicketPhase::as_ref);
let rows = self
.conn
.query(&sql, turso::params![workspace_name, status_str])
.await?;
let mut tickets = Vec::new();
for row in rows {
tickets.push(self.ticket_from_row(&row, false).await?);
}
Ok(tickets)
}
pub async fn count_by_status(
&self,
status: TicketPhase,
workspace_name: Option<&str>,
) -> Result<i64> {
self.conn
.query_row(
"SELECT COUNT(*) FROM tickets \
WHERE status = ?1 \
AND (?2 IS NULL OR workspace_name = ?2) \
AND is_archived = 0",
turso::params![status.as_ref(), workspace_name],
|row| row.get(0),
)
.await
.map_err(Into::into)
}
async fn collect_archive_candidates(
&self,
sql: &str,
params: impl turso::IntoParams + Send + 'static,
) -> Result<Vec<String>> {
let rows = self.conn.query(sql, params).await?;
let mut candidates = Vec::new();
for row in rows {
let id: String = row.get(0)?;
candidates.push(id);
}
Ok(candidates)
}
const ARCHIVE_CHUNK_SIZE: usize = 500;
async fn batch_set_archived(&self, items: &[String]) -> Result<u64> {
if items.is_empty() {
return Ok(0);
}
let now = turso::now();
let mut total: u64 = 0;
for chunk in items.chunks(Self::ARCHIVE_CHUNK_SIZE) {
let sql = format!(
"UPDATE tickets SET is_archived = 1, updated_at = ? \
WHERE id IN ({}) AND assigned_to IS NULL",
turso::sql_in_placeholders(chunk.len()),
);
let mut params: Vec<Value> = vec![Value::Text(now.clone())];
params.extend(chunk.iter().map(|id| Value::Text(id.clone())));
total += self
.conn
.execute(&sql, params_from_iter(params))
.await
.context("Failed to batch-archive tickets")?;
}
Ok(total)
}
pub async fn set_archived(&self, id: &str) -> Result<()> {
let now = turso::now();
self.execute_and_cancel(
"UPDATE tickets SET is_archived = 1, assigned_to = NULL, updated_at = ?1 \
WHERE id = ?2",
turso::params![now, id],
id,
"set archived",
)
.await
}
pub async fn archive_stale_cancelled(&self, hours: i64) -> Result<u64> {
let cutoff = (Utc::now() - Duration::hours(hours)).to_rfc3339();
let to_archive = self
.collect_archive_candidates(
"SELECT id FROM tickets \
WHERE status = ?1 AND updated_at < ?2 AND assigned_to IS NULL \
AND is_archived = 0",
turso::params![TicketPhase::Cancelled.as_ref(), cutoff],
)
.await?;
self.batch_set_archived(&to_archive).await
}
pub async fn archive_all_done_and_cancelled(
&self,
workspace_name: Option<&str>,
) -> Result<u64> {
let done_cancelled = [TicketPhase::Done, TicketPhase::Cancelled];
let select_sql = format!(
"SELECT id FROM tickets WHERE status IN ({}) \
AND assigned_to IS NULL AND is_archived = 0 \
AND (?1 IS NULL OR workspace_name = ?1)",
status_list_sql_fragment(&done_cancelled),
);
let to_archive = self
.collect_archive_candidates(&select_sql, turso::params![workspace_name])
.await?;
self.batch_set_archived(&to_archive).await
}
pub async fn search_archived_by_fts(
&self,
query: &str,
limit: usize,
) -> Result<Vec<(String, f64)>> {
let sanitized = crate::turso::sanitize_fts_query(query);
if sanitized.is_empty() {
return Ok(Vec::new());
}
let sql = format!(
"SELECT t.id, fts_score(t.title, ?1) AS score \
FROM tickets t \
WHERE t.is_archived = 1 \
AND t.title MATCH ?1 \
ORDER BY score DESC LIMIT {limit}"
);
match self
.conn
.query_map(&sql, turso::params![sanitized.clone()], |row| {
let id: String = row.get(0)?;
let score: f64 = row.get(1)?;
Ok::<_, anyhow::Error>((id, score))
})
.await
{
Ok(items) => {
let mut results = Vec::new();
for item in items {
results.push(item?);
}
Ok(results)
}
Err(e) => {
tracing::warn!(
query = %sanitized,
error = %e,
"FTS search for archived tickets failed"
);
Ok(Vec::new())
}
}
}
pub async fn list_archived_with_embeddings(&self) -> Result<Vec<(String, Vec<f32>)>> {
let rows = self
.conn
.query(
"SELECT id, embedding FROM tickets \
WHERE is_archived = 1 AND embedding IS NOT NULL",
turso::params![],
)
.await?;
let mut candidates: Vec<(String, Vec<f32>)> = Vec::new();
for row in &rows {
let id: String = row.get(0)?;
let stored: Vec<u8> = row.get(1)?;
let emb = crate::vector::bytes_to_vec(&stored);
candidates.push((id, emb));
}
Ok(candidates)
}
pub async fn list_tickets_minimal(
&self,
ids: &[String],
) -> Result<Vec<(String, String, String)>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let sql = format!(
"SELECT id, title, status FROM tickets WHERE id IN ({})",
turso::sql_in_placeholders(ids.len()),
);
let params: Vec<Value> = ids.iter().map(|id| Value::Text(id.clone())).collect();
let rows = self.conn.query(&sql, params_from_iter(params)).await?;
let mut map: HashMap<String, (String, String)> = HashMap::new();
for row in &rows {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
let status: String = row.get(2)?;
map.insert(id, (title, status));
}
let mut results = Vec::with_capacity(ids.len());
for id in ids {
if let Some((title, status)) = map.get(id) {
results.push((id.clone(), title.clone(), status.clone()));
}
}
Ok(results)
}
}
#[derive(Clone, Debug)]
pub struct BoardStore {
pub(crate) conn: Connection,
}
#[cfg(test)]
pub(crate) async fn open_test_store() -> (BoardStore, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().expect("temp dir");
let store = BoardStore::open(tmp.path()).await.expect("open store");
(store, tmp)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Role;
use crate::Tool;
use crate::util::test::expect_ticket;
use crate::workspace::test_ws;
use crate::workspace::test_ws_named;
use strum::IntoEnumIterator;
use tempfile::TempDir;
async fn setup() -> (BoardStore, TempDir, String) {
let (store, tmp) = crate::board::open_test_store().await;
let id = default_ticket(&store, "/ws", "ws", "test").await;
(store, tmp, id)
}
async fn default_ticket(
store: &BoardStore,
workspace_path: &str,
workspace_name: &str,
reporter: &str,
) -> String {
store
.create_ticket(
"Test",
"desc",
&test_ws_named(workspace_path, workspace_name),
DEFAULT_TICKET_PHASE,
&[],
reporter,
None,
)
.await
.expect("default_ticket")
}
#[tokio::test]
async fn test_create_and_get_ticket() {
let (store, _tmp) = open_test_store().await;
let id = store
.create_ticket(
"Test",
"A test ticket",
&crate::workspace::test_ws_named("/workspace", "workspace"),
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.title, "Test");
assert_eq!(ticket.description, "A test ticket");
assert_eq!(ticket.status, TicketPhase::Backlog);
assert!(ticket.assigned_to.is_none());
assert!(ticket.comments.is_empty());
assert!(
ticket.commit_hash.is_none(),
"new ticket should have no commit_hash"
);
assert!(
ticket.lines_added.is_none(),
"new ticket should have no lines_added"
);
assert!(
ticket.lines_removed.is_none(),
"new ticket should have no lines_removed"
);
}
#[tokio::test]
async fn test_get_ticket_status() {
let (store, _tmp) = open_test_store().await;
assert!(
store
.get_ticket_status("nonexistent")
.await
.expect("query")
.is_none()
);
let id = store
.create_ticket(
"Status Test",
"Testing get_ticket_status",
&crate::workspace::test_ws_named("/workspace", "workspace"),
TicketPhase::Planning,
&[],
"test",
None,
)
.await
.expect("create");
let status = crate::util::test::expect_ticket_status(&store, &id).await;
assert_eq!(status, TicketPhase::Planning);
store
.transition_to(&id, None, TicketPhase::ReadyForDevelopment)
.await
.expect("set");
let status = crate::util::test::expect_ticket_status(&store, &id).await;
assert_eq!(status, TicketPhase::ReadyForDevelopment);
}
#[tokio::test]
async fn test_backlog_phases_roundtrip() {
let (store, _tmp) = open_test_store().await;
store
.create_ticket(
"Backlog Test",
"A backlog ticket",
&test_ws_named("/workspace", "workspace"),
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let variants: &[(&str, TicketPhase)] = &[
("backlog", TicketPhase::Backlog),
("analysis", TicketPhase::Analysis),
("planning", TicketPhase::Planning),
("ready_for_development", TicketPhase::ReadyForDevelopment),
("in_development", TicketPhase::InDevelopment),
("in_diagnostics", TicketPhase::InDiagnostics),
("diagnostics_done", TicketPhase::DiagnosticsDone),
("in_review", TicketPhase::InReview),
("reviewed", TicketPhase::Reviewed),
("in_qa", TicketPhase::InQa),
("qa_passed", TicketPhase::QaPassed),
("done", TicketPhase::Done),
("cancelled", TicketPhase::Cancelled),
("failed", TicketPhase::Failed),
("paused", TicketPhase::Paused),
];
for (s, expected) in variants {
assert_eq!(&s.parse::<TicketPhase>().unwrap(), expected, "variant: {s}");
}
for v in TicketPhase::iter() {
let parsed: TicketPhase = v.as_ref().parse().unwrap();
assert_eq!(&parsed, &v, "roundtrip failed for {v}");
}
assert!("unknown_phase".parse::<TicketPhase>().is_err());
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::Analysis,
"workspace",
false,
)
.await
.expect("claim");
assert!(claimed.is_some());
let claimed = claimed.unwrap();
assert_eq!(claimed.status, TicketPhase::Analysis);
}
#[test]
fn test_display_name_no_underscores() {
for variant in TicketPhase::iter() {
let name = variant.display_name();
assert!(!name.is_empty(), "empty display_name for {variant}");
assert!(
!name.contains('_'),
"display_name for {variant} still has underscore: {name}"
);
}
}
#[tokio::test]
async fn test_get_nonexistent_ticket() {
let (store, _tmp) = open_test_store().await;
let ticket = store.get_ticket("nonexistent").await.expect("get");
assert!(ticket.is_none());
}
#[tokio::test]
async fn test_unconditional_transition_clears_assignment() {
let (store, _tmp, id) = setup().await;
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"ws",
false,
)
.await
.expect("claim")
.expect("ticket exists");
store
.set_assigned_to(&claimed.id, Some(Role::Engineer.as_str()))
.await
.expect("set_assigned_to");
let ticket = store
.get_ticket(&id)
.await
.expect("get")
.expect("should exist");
assert!(
ticket.assigned_to.is_some(),
"assigned_to should be set after set_assigned_to"
);
store
.transition_to(&id, None, TicketPhase::DiagnosticsDone)
.await
.expect("update");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.status, TicketPhase::DiagnosticsDone);
assert!(
ticket.assigned_to.is_none(),
"assigned_to should be cleared after unconditional transition"
);
}
#[tokio::test]
async fn test_guarded_transition_with_wrong_phase_fails() {
let (store, _tmp, id) = setup().await;
let result = store
.transition_to(&id, Some(TicketPhase::Done), TicketPhase::InDevelopment)
.await;
assert!(
result.is_err(),
"guarded transition with wrong phase should fail"
);
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.status, TicketPhase::Backlog);
}
#[tokio::test]
async fn test_guarded_transition_with_correct_phase_succeeds() {
let (store, _tmp, id) = setup().await;
store
.transition_to(&id, Some(TicketPhase::Backlog), TicketPhase::InDevelopment)
.await
.expect("guarded transition with correct phase should succeed");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.status, TicketPhase::InDevelopment);
}
#[tokio::test]
async fn test_add_comment() {
let (store, _tmp, id) = setup().await;
store
.add_comment(&id, Role::Engineer.as_str(), "done!")
.await
.expect("add comment");
let comments = store.get_comments(&id).await.expect("get comments");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].role, Role::Engineer.as_str());
assert_eq!(comments[0].content, "done!");
assert!(!comments[0].created_at.is_empty());
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert!(ticket.updated_at > ticket.created_at);
}
#[tokio::test]
async fn test_list_tickets() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
store
.create_ticket("A", "desc", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create");
store
.create_ticket("B", "desc", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create");
store
.create_ticket("C", "desc", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create");
let tickets = store
.list_all_tickets(Some("ws"), None)
.await
.expect("list");
assert_eq!(tickets.len(), 3);
let tickets = store
.list_all_tickets(Some("ws"), Some(TicketPhase::Done))
.await
.expect("list");
assert_eq!(tickets.len(), 0);
}
#[tokio::test]
async fn test_reset_inflight_tickets_new() {
let (store, _tmp) = open_test_store().await;
let ticket1 = default_ticket(&store, "/ws", "ws", "test").await;
let ticket2 = default_ticket(&store, "/ws", "ws", "test").await;
store.reset_inflight_tickets().await.expect("reset");
for id in [&ticket1, &ticket2] {
let t = store
.get_ticket(id)
.await
.expect("get")
.expect("should exist");
assert_eq!(
t.status,
TicketPhase::Backlog,
"new tickets stay backlog (not in any inflight phase)"
);
assert!(t.assigned_to.is_none(), "assigned_to should be cleared");
}
}
#[tokio::test]
async fn test_reset_inflight_tickets_sets_reservation() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let in_dev_id = store
.create_ticket(
"InDev",
"desc",
&ws,
TicketPhase::InDevelopment,
&[],
"test",
None,
)
.await
.expect("create InDevelopment ticket");
let analysis_id = store
.create_ticket(
"Analysis",
"desc",
&ws,
TicketPhase::Analysis,
&[],
"test",
None,
)
.await
.expect("create Analysis ticket");
store.reset_inflight_tickets().await.expect("reset");
let t = expect_ticket(&store, &in_dev_id).await;
assert_eq!(
t.status,
TicketPhase::ReadyForDevelopment,
"InDevelopment should reset to ReadyForDevelopment"
);
assert!(
t.pipeline_reservation,
"InDevelopment reset should set pipeline_reservation = 1"
);
let t = expect_ticket(&store, &analysis_id).await;
assert_eq!(
t.status,
TicketPhase::Backlog,
"Analysis should reset to Backlog"
);
assert!(
!t.pipeline_reservation,
"Analysis reset should NOT set pipeline_reservation"
);
}
#[tokio::test]
async fn test_claim_prefers_reserved_ticket() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let fresh_id = store
.create_ticket(
"Fresh",
"desc",
&ws,
TicketPhase::ReadyForDevelopment,
&[],
"test",
None,
)
.await
.expect("create fresh ticket");
let reserved_id = store
.create_ticket(
"Reserved",
"desc",
&ws,
TicketPhase::ReadyForDevelopment,
&[],
"test",
None,
)
.await
.expect("create reserved ticket");
store
.transition_to_with_reservation(
&reserved_id,
Some(TicketPhase::ReadyForDevelopment),
TicketPhase::ReadyForDevelopment,
true,
)
.await
.expect("set reservation");
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::ReadyForDevelopment,
TicketPhase::InDevelopment,
"ws",
true,
)
.await
.expect("claim")
.expect("should claim a ticket");
assert_eq!(
claimed.id, reserved_id,
"Reserved ticket should be claimed before fresh one"
);
assert!(
!claimed.pipeline_reservation,
"Claim should clear pipeline_reservation"
);
let reserved_db = expect_ticket(&store, &reserved_id).await;
assert!(
!reserved_db.pipeline_reservation,
"Reservation should be 0 in DB after claim"
);
let fresh = expect_ticket(&store, &fresh_id).await;
assert_eq!(
fresh.status,
TicketPhase::ReadyForDevelopment,
"Fresh ticket should still be at ReadyForDevelopment"
);
assert!(
!fresh.pipeline_reservation,
"Fresh ticket should have no reservation"
);
}
#[tokio::test]
async fn test_has_pipeline_blocker_reserved() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let id = store
.create_ticket(
"Fresh",
"desc",
&ws,
TicketPhase::ReadyForDevelopment,
&[],
"test",
None,
)
.await
.expect("create");
assert!(
!store
.has_pipeline_blocker_for_workspace("ws")
.await
.expect("check"),
"Fresh ReadyForDevelopment ticket should not be a pipeline blocker"
);
store
.transition_to_with_reservation(
&id,
Some(TicketPhase::ReadyForDevelopment),
TicketPhase::ReadyForDevelopment,
true,
)
.await
.expect("set reservation");
assert!(
store
.has_pipeline_blocker_for_workspace("ws")
.await
.expect("check"),
"Reserved ReadyForDevelopment ticket should be a pipeline blocker"
);
store
.transition_to_with_reservation(
&id,
Some(TicketPhase::ReadyForDevelopment),
TicketPhase::ReadyForDevelopment,
false,
)
.await
.expect("clear reservation");
assert!(
!store
.has_pipeline_blocker_for_workspace("ws")
.await
.expect("check"),
"Non-reserved ReadyForDevelopment ticket should not be a pipeline blocker again"
);
}
#[test]
fn test_pipeline_blockers_coverage() {
for phase in TRANSITORY_HANDOFF_PHASES {
assert!(
PIPELINE_BLOCKING_STATUSES.contains(phase),
"\
TRANSITORY_HANDOFF_PHASES contains `{phase}` which is not in \
PIPELINE_BLOCKING_STATUSES. Every transitory handoff phase must also \
be a pipeline blocker.\
",
);
}
let reset_from: Vec<TicketPhase> = BoardStore::RESET_TRANSITIONS
.iter()
.map(|(from, _, _)| *from)
.collect();
for phase in PIPELINE_BLOCKING_STATUSES {
let has_reset = reset_from.contains(phase);
assert!(
has_reset || phase.is_transitory_handoff(),
"\
PIPELINE_BLOCKING_STATUSES contains `{phase}` which has no corresponding \
entry in RESET_TRANSITIONS and is not a transitory handoff phase \
(see `TicketPhase::is_transitory_handoff`). Either add a reset transition to \
RESET_TRANSITIONS, or mark the phase as transitory handoff in that method \
with a comment explaining why no agent is mid-execution in that state.\
",
);
}
}
#[tokio::test]
async fn test_claim_ticket_in_workspace() {
let (store, _tmp) = open_test_store().await;
let ws_a = test_ws_named("/ws_a", "workspace_a");
let ws_b = test_ws_named("/ws_b", "workspace_b");
let id_a = store
.create_ticket(
"Ticket A",
"desc",
&ws_a,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create ticket in ws_a");
let id_b = store
.create_ticket(
"Ticket B",
"desc",
&ws_b,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create ticket in ws_b");
let claimed_a = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"workspace_a",
false,
)
.await
.expect("claim in ws_a")
.expect("should claim ticket from ws_a");
assert_eq!(claimed_a.id, id_a);
assert_eq!(claimed_a.workspace_name, "workspace_a");
assert_eq!(claimed_a.status, TicketPhase::InDevelopment);
assert!(claimed_a.assigned_to.is_none());
assert!(
store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"workspace_a",
false,
)
.await
.expect("second claim in ws_a")
.is_none(),
"no more tickets to claim in ws_a"
);
let claimed_b = store
.claim_ticket_in_workspace(
TicketPhase::Backlog,
TicketPhase::InDevelopment,
"workspace_b",
false,
)
.await
.expect("claim in ws_b")
.expect("should claim ticket from ws_b");
assert_eq!(claimed_b.id, id_b);
assert_eq!(claimed_b.workspace_name, "workspace_b");
}
#[allow(clippy::too_many_lines)]
#[tokio::test]
async fn test_claim_ticket_in_workspace_if_pipeline_free() {
enum Scenario {
SameWorkspace(TicketPhase),
DifferentWorkspace(TicketPhase),
NoBlocker,
}
struct Case {
name: &'static str,
suffix: &'static str,
scenario: Scenario,
}
let cases = [
Case {
name: "blocked by same-workspace pipeline ticket",
suffix: "blocked",
scenario: Scenario::SameWorkspace(TicketPhase::InReview),
},
Case {
name: "not blocked by cross-workspace pipeline ticket",
suffix: "cross",
scenario: Scenario::DifferentWorkspace(TicketPhase::InDevelopment),
},
Case {
name: "no blocker succeeds",
suffix: "none",
scenario: Scenario::NoBlocker,
},
];
let (store, _tmp) = open_test_store().await;
for case in &cases {
let suffix = case.suffix;
let (claim_ws_name, blocker_ws_name) = match &case.scenario {
Scenario::DifferentWorkspace(_) => (
format!("ws_{suffix}_claimable"),
format!("ws_{suffix}_blocker"),
),
Scenario::SameWorkspace(_) | Scenario::NoBlocker => {
let name = format!("ws_{suffix}");
(name.clone(), name)
}
};
let expected_claim = !matches!(case.scenario, Scenario::SameWorkspace(_));
let blocker_ws = test_ws_named(&format!("/{blocker_ws_name}"), &blocker_ws_name);
let claimable_ws = test_ws_named(&format!("/{claim_ws_name}"), &claim_ws_name);
if let Scenario::SameWorkspace(phase) | Scenario::DifferentWorkspace(phase) =
&case.scenario
{
let blocker_target = match &case.scenario {
Scenario::DifferentWorkspace(_) => &blocker_ws,
Scenario::SameWorkspace(_) => &claimable_ws,
Scenario::NoBlocker => unreachable!(),
};
store
.create_ticket(
"Blocker",
"already in pipeline",
blocker_target,
*phase,
&[],
"test",
None,
)
.await
.expect("create blocker");
}
let id = store
.create_ticket(
"Claimable",
"ready for dev",
&claimable_ws,
TicketPhase::ReadyForDevelopment,
&[],
"test",
None,
)
.await
.expect("create claimable");
let claimed = store
.claim_ticket_in_workspace(
TicketPhase::ReadyForDevelopment,
TicketPhase::InDevelopment,
&claim_ws_name,
true,
)
.await
.expect("claim should not error");
if expected_claim {
let claimed = claimed.expect("should claim ticket");
assert_eq!(claimed.id, id, "Case '{}': wrong ticket id", case.name);
assert_eq!(
claimed.status,
TicketPhase::InDevelopment,
"Case '{}': wrong status after claim",
case.name
);
} else {
assert!(
claimed.is_none(),
"Case '{}': claim should be blocked",
case.name
);
}
}
}
#[tokio::test]
async fn test_create_ticket_with_prerequisites() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let p1 = store
.create_ticket(
"P1",
"prereq one",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create p1");
let p2 = store
.create_ticket(
"P2",
"prereq two",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create p2");
let deps = vec![p1.clone(), p2.clone()];
let id = store
.create_ticket(
"Dependent",
"needs both",
&ws,
DEFAULT_TICKET_PHASE,
&deps,
"test",
None,
)
.await
.expect("create dependent");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.prerequisites.len(), 2);
assert!(ticket.prerequisites.contains(&p1));
assert!(ticket.prerequisites.contains(&p2));
}
#[tokio::test]
async fn test_create_ticket_nonexistent_prereq_error() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let result = store
.create_ticket(
"Bad",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[String::from("nonexistent-1")],
"test",
None,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found"),
"expected 'not found' in error, got: {err}"
);
}
#[tokio::test]
async fn test_create_ticket_self_reference_error() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let _first = store
.create_ticket("First", "any", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create first");
let result = store
.create_ticket(
"SelfReferencing",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[String::from("ws-1")],
"test",
None,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("cannot depend on itself"),
"expected 'cannot depend on itself' in error, got: {err}"
);
}
#[tokio::test]
async fn test_create_ticket_cross_workspace_error() {
let (store, _tmp) = open_test_store().await;
let ws_a = test_ws_named("/ws_a", "workspace_a");
let ws_b = test_ws_named("/ws_b", "workspace_b");
let pa = store
.create_ticket("PA", "in A", &ws_a, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create pa");
let result = store
.create_ticket(
"InB",
"depends on A",
&ws_b,
DEFAULT_TICKET_PHASE,
std::slice::from_ref(&pa),
"test",
None,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Cross-workspace"),
"expected 'Cross-workspace' in error, got: {err}"
);
}
#[tokio::test]
async fn test_circular_dependency_rejected() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let a = store
.create_ticket("A", "first", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create a");
let b = store
.create_ticket(
"B",
"second",
&ws,
DEFAULT_TICKET_PHASE,
std::slice::from_ref(&a),
"test",
None,
)
.await
.expect("create b");
let _c = store
.create_ticket(
"C",
"depends on both",
&ws,
DEFAULT_TICKET_PHASE,
&[a.clone(), b.clone()],
"test",
None,
)
.await
.expect("create c — A and B as prereqs is not a cycle");
}
#[tokio::test]
async fn test_blocked_ticket_not_claimable() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let p = store
.create_ticket("P", "prereq", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create p");
let d_id = store
.create_ticket(
"D",
"dependent",
&ws,
DEFAULT_TICKET_PHASE,
std::slice::from_ref(&p),
"test",
None,
)
.await
.expect("create d");
let d_ticket = crate::util::test::expect_ticket(&store, &d_id).await;
assert_eq!(d_ticket.prerequisites, vec![p.clone()]);
let claimed = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim");
assert!(claimed.is_some(), "should claim P (no unmet prereqs)");
let claimed = claimed.unwrap();
assert_eq!(
claimed.id, p,
"should have claimed the unblocked ticket P, not D"
);
let second = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim");
assert!(
second.is_none(),
"D should be blocked because P is in analysis, not done"
);
}
#[tokio::test]
async fn test_unblocked_after_prereq_done() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let p = store
.create_ticket("P", "prereq", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create p");
let d = store
.create_ticket(
"D",
"dependent",
&ws,
DEFAULT_TICKET_PHASE,
std::slice::from_ref(&p),
"test",
None,
)
.await
.expect("create d");
let blocked = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim");
assert_eq!(blocked.unwrap().id, p);
store
.transition_to(&p, None, TicketPhase::Done)
.await
.expect("set done");
let unblocked = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim");
assert!(unblocked.is_some(), "D should be claimable after P is done");
assert_eq!(unblocked.unwrap().id, d);
}
#[tokio::test]
async fn test_transitive_prerequisites_block() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let a = store
.create_ticket("A", "leaf", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create a");
let b = store
.create_ticket(
"B",
"middle",
&ws,
DEFAULT_TICKET_PHASE,
std::slice::from_ref(&a),
"test",
None,
)
.await
.expect("create b");
let c = store
.create_ticket(
"C",
"top",
&ws,
DEFAULT_TICKET_PHASE,
std::slice::from_ref(&b),
"test",
None,
)
.await
.expect("create c");
let claimed = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim")
.expect("should claim A");
assert_eq!(claimed.id, a);
store
.transition_to(&a, None, TicketPhase::Done)
.await
.expect("done a");
let claimed2 = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim")
.expect("should claim B");
assert_eq!(claimed2.id, b);
store
.transition_to(&b, None, TicketPhase::Done)
.await
.expect("done b");
let claimed3 = store
.claim_ticket_in_workspace(TicketPhase::Backlog, TicketPhase::Analysis, "ws", false)
.await
.expect("claim")
.expect("should claim C");
assert_eq!(claimed3.id, c);
}
#[tokio::test]
async fn test_archive_stale_cancelled() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let old_cancelled_id = store
.create_ticket(
"old-cancelled",
"desc",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create_ticket");
let two_hours_ago = (Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
store
.transition_to(&old_cancelled_id, None, TicketPhase::Cancelled)
.await
.expect("cancel");
store
.conn
.execute(
"UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
crate::turso::params![two_hours_ago.clone(), old_cancelled_id.clone()],
)
.await
.expect("backdate");
let fresh_cancelled_id = store
.create_ticket(
"fresh-cancelled",
"desc",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create_ticket");
store
.transition_to(&fresh_cancelled_id, None, TicketPhase::Cancelled)
.await
.expect("cancel");
let old_backlog_id = store
.create_ticket(
"old-backlog",
"desc",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create_ticket");
store
.conn
.execute(
"UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
crate::turso::params![two_hours_ago.clone(), old_backlog_id.clone()],
)
.await
.expect("backdate");
let count = store
.archive_stale_cancelled(1)
.await
.expect("archive_stale_cancelled");
assert_eq!(count, 1, "should archive only the old cancelled ticket");
let old_cancelled = crate::util::test::expect_ticket(&store, &old_cancelled_id).await;
assert!(
old_cancelled.is_archived,
"old cancelled ticket should be archived"
);
assert_eq!(old_cancelled.status, TicketPhase::Cancelled);
let fresh_cancelled = crate::util::test::expect_ticket(&store, &fresh_cancelled_id).await;
assert!(
!fresh_cancelled.is_archived,
"fresh cancelled ticket should NOT be archived"
);
assert_eq!(fresh_cancelled.status, TicketPhase::Cancelled);
let old_backlog = crate::util::test::expect_ticket(&store, &old_backlog_id).await;
assert!(
!old_backlog.is_archived,
"old non-cancelled ticket should NOT be archived"
);
assert_eq!(old_backlog.status, TicketPhase::Backlog);
}
#[tokio::test]
async fn test_archive_stale_cancelled_empty_db() {
let (store, _tmp) = open_test_store().await;
let count = store
.archive_stale_cancelled(1)
.await
.expect("archive_stale_cancelled");
assert_eq!(count, 0, "Empty DB should return 0");
}
#[tokio::test]
async fn test_archive_all_done_and_cancelled() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let done_id = store
.create_ticket("done", "desc", &ws, TicketPhase::Backlog, &[], "test", None)
.await
.expect("create_ticket");
store
.transition_to(&done_id, None, TicketPhase::Done)
.await
.expect("set done");
let cancelled_id = store
.create_ticket(
"cancelled",
"desc",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create_ticket");
store
.transition_to(&cancelled_id, None, TicketPhase::Cancelled)
.await
.expect("cancel");
let backlog_id = store
.create_ticket(
"backlog",
"desc",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create_ticket");
let count = store
.archive_all_done_and_cancelled(None)
.await
.expect("archive");
assert_eq!(count, 2, "should archive Done and Cancelled tickets");
let done_ticket = crate::util::test::expect_ticket(&store, &done_id).await;
assert!(done_ticket.is_archived, "Done ticket should be archived");
assert_eq!(done_ticket.status, TicketPhase::Done);
let cancelled_ticket = crate::util::test::expect_ticket(&store, &cancelled_id).await;
assert!(
cancelled_ticket.is_archived,
"Cancelled ticket should be archived"
);
assert_eq!(cancelled_ticket.status, TicketPhase::Cancelled);
let backlog_ticket = crate::util::test::expect_ticket(&store, &backlog_id).await;
assert!(
!backlog_ticket.is_archived,
"Backlog ticket should NOT be archived"
);
assert_eq!(backlog_ticket.status, TicketPhase::Backlog);
}
#[tokio::test]
async fn test_archive_all_done_and_cancelled_empty_db() {
let (store, _tmp) = open_test_store().await;
let count = store
.archive_all_done_and_cancelled(None)
.await
.expect("archive_all_done_and_cancelled");
assert_eq!(count, 0, "Empty DB should return 0");
}
#[tokio::test]
async fn test_archive_all_done_and_cancelled_workspace_filter() {
let (store, _tmp) = open_test_store().await;
let id1 = default_ticket(&store, "/ws1", "ws1", "test").await;
store
.transition_to(&id1, None, TicketPhase::Done)
.await
.expect("set done");
let id2 = default_ticket(&store, "/ws2", "ws2", "test").await;
store
.transition_to(&id2, None, TicketPhase::Done)
.await
.expect("set done");
let count = store
.archive_all_done_and_cancelled(Some("ws1"))
.await
.expect("archive_all_done_and_cancelled");
assert_eq!(count, 1, "Should archive only ws1 ticket");
let ticket1 = crate::util::test::expect_ticket(&store, &id1).await;
assert!(ticket1.is_archived, "ws1 ticket should be archived");
assert_eq!(
ticket1.status,
TicketPhase::Done,
"ws1 status should remain Done"
);
let ticket2 = crate::util::test::expect_ticket(&store, &id2).await;
assert!(!ticket2.is_archived, "ws2 ticket should NOT be archived");
assert_eq!(
ticket2.status,
TicketPhase::Done,
"ws2 ticket should remain Done"
);
}
#[tokio::test]
async fn test_count_by_status_excludes_archived() {
let (store, _tmp) = open_test_store().await;
let _ws = test_ws_named("/ws", "ws");
let id = default_ticket(&store, "/ws", "ws", "test").await;
store
.transition_to(&id, None, TicketPhase::Done)
.await
.expect("set done");
let count_before = store
.count_by_status(TicketPhase::Done, None)
.await
.expect("count before");
assert_eq!(count_before, 1, "Should count Done ticket before archive");
let archived = store
.archive_all_done_and_cancelled(None)
.await
.expect("archive");
assert_eq!(archived, 1, "Should have archived 1 ticket");
let count_after = store
.count_by_status(TicketPhase::Done, None)
.await
.expect("count after");
assert_eq!(count_after, 0, "Should not count archived Done tickets");
let count_cancelled = store
.count_by_status(TicketPhase::Cancelled, None)
.await
.expect("count cancelled");
assert_eq!(count_cancelled, 0, "No Cancelled tickets exist");
}
#[tokio::test]
async fn test_create_ticket_tool_with_prerequisites() {
crate::util::test::init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws("/tmp/test_ws_tool_prereqs");
let p_id = store
.create_ticket(
"Pre",
"a prereq",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create prereq");
let tool = crate::tools::CreateTicketTool::new("test");
let args = serde_json::json!({
"title": "Test with prereqs",
"description": "depends on something",
"prerequisites": [p_id],
});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains(&p_id),
"Output should mention prerequisite ID"
);
}
#[tokio::test]
async fn test_supersede_and_create_basic() {
let (store, _tmp, old_id) = setup().await;
let ws = test_ws_named("/ws", "ws");
let new_id = store
.supersede_and_create(&old_id, "New title", "New desc", &ws, &[], "test", None)
.await
.expect("supersede");
let old = store
.get_ticket(&old_id)
.await
.expect("get old")
.expect("old exists");
assert_eq!(old.status, TicketPhase::Cancelled);
assert!(old.assigned_to.is_none());
assert_eq!(old.superseded_by.as_deref(), Some(new_id.as_str()));
assert!(
old.is_archived,
"superseded ticket should be archived immediately"
);
let new = store
.get_ticket(&new_id)
.await
.expect("get new")
.expect("new exists");
assert_eq!(new.status, TicketPhase::Backlog);
assert_eq!(new.supersedes.as_deref(), Some(old_id.as_str()));
assert_eq!(new.title, "New title");
}
#[tokio::test]
async fn test_supersede_rewires_only_matching_prerequisite() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let a_id = store
.create_ticket("A", "old", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create A");
let c_id = store
.create_ticket("C", "other", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create C");
let b_id = store
.create_ticket(
"B",
"dep on A and C",
&ws,
DEFAULT_TICKET_PHASE,
&[a_id.clone(), c_id.clone()],
"test",
None,
)
.await
.expect("create B");
let d_id = store
.create_ticket("D", "no deps", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create D");
let supersede_id = store
.supersede_and_create(&a_id, "A2", "refined", &ws, &[], "test", None)
.await
.expect("supersede");
let b = store
.get_ticket(&b_id)
.await
.expect("get B")
.expect("B exists");
assert_eq!(b.prerequisites, vec![supersede_id.clone(), c_id.clone()]);
let d = store
.get_ticket(&d_id)
.await
.expect("get D")
.expect("D exists");
assert!(d.prerequisites.is_empty());
}
#[tokio::test]
async fn test_supersede_invalid_inputs() {
enum Scenario {
NonExistent,
CrossWorkspace,
SelfReference,
}
struct Case {
name: &'static str,
scenario: Scenario,
}
let cases = [
Case {
name: "nonexistent original",
scenario: Scenario::NonExistent,
},
Case {
name: "cross-workspace supersede",
scenario: Scenario::CrossWorkspace,
},
Case {
name: "self-referencing prerequisites",
scenario: Scenario::SelfReference,
},
];
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let ws_b = test_ws_named("/ws_b", "ws_b");
for case in &cases {
let expected_error = match case.scenario {
Scenario::NonExistent => "not found",
Scenario::CrossWorkspace => "Cross-workspace",
Scenario::SelfReference => "supersede and depend",
};
let original_id = match case.scenario {
Scenario::NonExistent => None,
Scenario::CrossWorkspace | Scenario::SelfReference => {
let id = store
.create_ticket("A", "desc", &ws, DEFAULT_TICKET_PHASE, &[], "test", None)
.await
.expect("create original");
Some(id)
}
};
let target_ws = match case.scenario {
Scenario::CrossWorkspace => &ws_b,
Scenario::NonExistent | Scenario::SelfReference => &ws,
};
let supersede_id: &str = original_id.as_deref().unwrap_or("nonexistent");
let prereqs: Vec<String> = match &case.scenario {
Scenario::SelfReference => {
vec![
original_id
.clone()
.expect("original must exist for SelfReference"),
]
}
Scenario::NonExistent | Scenario::CrossWorkspace => vec![],
};
let err = store
.supersede_and_create(
supersede_id,
"New",
"desc",
target_ws,
&prereqs,
"test",
None,
)
.await
.unwrap_err();
assert!(
err.to_string().contains(expected_error),
"Case '{}': expected error containing '{}', got: {err}",
case.name,
expected_error
);
}
}
#[tokio::test]
async fn test_supersede_tool() {
crate::util::test::init_test_stores().await;
let store = crate::board::BOARD.get().unwrap();
let ws = test_ws("/tmp/test_ws_supersede_tool");
let old_id = store
.create_ticket(
"Old",
"old desc",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create old");
let tool = crate::tools::CreateTicketTool::new("test");
let args = serde_json::json!({
"title": "Refined",
"description": "refined desc",
"supersede": old_id,
});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains("Superseded"),
"Output should say Superseded: {result}"
);
assert!(
result.contains(&old_id),
"Output should mention old ID: {result}"
);
let old = store
.get_ticket(&old_id)
.await
.expect("get old")
.expect("old exists");
assert_eq!(old.status, TicketPhase::Cancelled);
assert!(
old.is_archived,
"superseded ticket should be archived immediately"
);
}
#[tokio::test]
async fn test_supersede_already_cancelled() {
let (store, _tmp, old_id) = setup().await;
let ws = test_ws_named("/ws", "ws");
store
.transition_to(&old_id, None, TicketPhase::Cancelled)
.await
.expect("cancel");
let new_id = store
.supersede_and_create(&old_id, "Refined", "desc", &ws, &[], "test", None)
.await
.expect("supersede already-cancelled");
let new = store
.get_ticket(&new_id)
.await
.expect("get new")
.expect("new exists");
assert_eq!(new.supersedes.as_deref(), Some(old_id.as_str()));
let old = store
.get_ticket(&old_id)
.await
.expect("get old")
.expect("old exists");
assert!(
old.is_archived,
"superseded ticket should be archived immediately"
);
}
#[tokio::test]
async fn test_set_commit_info() {
let (store, _tmp, id) = setup().await;
store
.set_commit_info(&id, "abcdef0123456789abcdef0123456789abcd0123", 10, 5)
.await
.expect("set commit info");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(
ticket.commit_hash.as_deref(),
Some("abcdef0123456789abcdef0123456789abcd0123")
);
assert_eq!(ticket.lines_added, Some(10));
assert_eq!(ticket.lines_removed, Some(5));
let (store2, _tmp2) = open_test_store().await;
let result = store2
.set_commit_info(
"nonexistent",
"0000000000000000000000000000000000000000",
0,
0,
)
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("nonexistent"),
"error should mention ticket id: {msg}"
);
}
#[test]
fn test_parse_prereqs() {
let valid: &[(&str, &[&str])] = &[
("[]", &[] as &[&str]),
(r#"["a","b","c"]"#, &["a", "b", "c"]),
];
for (input, expected) in valid {
let got = parse_prereqs(input).expect("should parse valid JSON");
assert_eq!(got, *expected, "input: {input:?}");
}
let invalid: &[&str] = &["", "not valid json {{{", r#"{"key":"value"}"#, "[1, 2, 3]"];
for input in invalid {
let err = parse_prereqs(input).unwrap_err();
assert!(
err.to_string().contains("Corrupt prerequisites JSON"),
"input {input:?}: expected 'Corrupt prerequisites JSON' error, got: {err}",
);
}
let long = format!(r#""{}...""#, "x".repeat(500));
let msg = parse_prereqs(&long).unwrap_err().to_string();
assert!(
msg.contains('…'),
"long input should produce truncated preview: {msg}"
);
assert!(
msg.len() < 500,
"truncated message should be <500 chars, got len={}",
msg.len()
);
let raw = format!("{}éééééééééémore", "x".repeat(199));
assert!(raw.len() > 200, "need raw longer than 200 chars");
assert!(
!raw.is_char_boundary(200),
"byte 200 must be mid-character for this test to be meaningful"
);
let msg = parse_prereqs(&raw).unwrap_err().to_string();
assert!(
msg.contains('…'),
"multi-byte input should produce truncated preview: {msg}"
);
assert!(
msg.len() < raw.len() + 50,
"message too long after truncation: len={}, raw.len()={}",
msg.len(),
raw.len()
);
assert!(
msg.contains("Corrupt prerequisites JSON"),
"should mention corrupt JSON: {msg}"
);
}
#[tokio::test]
async fn corrupt_prerequisites_causes_get_ticket_error() {
let (store, _tmp, id) = setup().await;
store
.conn
.execute(
"UPDATE tickets SET prerequisites = ?1 WHERE id = ?2",
crate::turso::params!["{not valid json}", id.clone()],
)
.await
.expect("corrupt update");
let result = store.get_ticket(&id).await;
assert!(
result.is_err(),
"get_ticket should fail when prerequisites are corrupt"
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("Corrupt prerequisites JSON"),
"error should mention corrupt JSON: {msg}"
);
assert!(
msg.contains(&id),
"error should include ticket ID {id}: {msg}"
);
}
#[tokio::test]
async fn corrupt_prerequisites_causes_list_all_tickets_error() {
let (store, _tmp, id) = setup().await;
store
.conn
.execute(
"UPDATE tickets SET prerequisites = ?1 WHERE id = ?2",
crate::turso::params!["garbage{{{", id.clone()],
)
.await
.expect("corrupt update");
let result = store.list_all_tickets(Some("ws"), None).await;
assert!(
result.is_err(),
"list_all_tickets should fail when any ticket has corrupt prerequisites"
);
}
#[tokio::test]
async fn test_claim_diagnostics() {
#[allow(clippy::struct_excessive_bools)]
struct Case {
name: &'static str,
move_to_diagnostics: bool,
pre_assigned: bool,
expected_claim: bool,
check_idempotent: bool,
}
let cases = [
Case {
name: "unassigned in diagnostics succeeds",
move_to_diagnostics: true,
pre_assigned: false,
expected_claim: true,
check_idempotent: true,
},
Case {
name: "already assigned fails",
move_to_diagnostics: true,
pre_assigned: true,
expected_claim: false,
check_idempotent: false,
},
Case {
name: "wrong phase fails",
move_to_diagnostics: false,
pre_assigned: false,
expected_claim: false,
check_idempotent: false,
},
];
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
for (i, case) in cases.iter().enumerate() {
let title = format!("claim-{i}");
let id = store
.create_ticket(&title, "desc", &ws, TicketPhase::Backlog, &[], "test", None)
.await
.expect("create_ticket");
if case.move_to_diagnostics {
store
.transition_to(&id, None, TicketPhase::InDiagnostics)
.await
.expect("transition to InDiagnostics");
}
if case.pre_assigned {
store
.set_assigned_to(&id, Some("diagnostics"))
.await
.expect("set_assigned_to");
}
let claimed = store
.claim_diagnostics(&id)
.await
.expect("claim_diagnostics");
assert_eq!(
claimed, case.expected_claim,
"Case '{}': unexpected claim result",
case.name
);
if case.expected_claim {
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(
ticket.assigned_to.as_deref(),
Some("diagnostics"),
"Case '{}': assignee should be set",
case.name
);
assert_eq!(
ticket.status,
TicketPhase::InDiagnostics,
"Case '{}': status should remain InDiagnostics",
case.name
);
}
if case.check_idempotent {
let second = store.claim_diagnostics(&id).await.expect("second claim");
assert!(
!second,
"Case '{}': second claim should return false (idempotent)",
case.name
);
}
}
}
#[tokio::test]
async fn test_set_assigned_to_none() {
let (store, _tmp, id) = setup().await;
store
.set_assigned_to(&id, Some("diagnostics"))
.await
.expect("set_assigned_to");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert_eq!(ticket.assigned_to.as_deref(), Some("diagnostics"));
store
.set_assigned_to(&id, None)
.await
.expect("set_assigned_to(None) should clear assignee");
let ticket = crate::util::test::expect_ticket(&store, &id).await;
assert!(ticket.assigned_to.is_none(), "assigned_to should be NULL");
store
.set_assigned_to(&id, None)
.await
.expect("second set_assigned_to(None) should also succeed");
let (store2, _tmp2) = open_test_store().await;
let result = store2.set_assigned_to("nonexistent", None).await;
assert!(
result.is_err(),
"set_assigned_to(None) on nonexistent ticket should fail"
);
}
#[tokio::test]
async fn test_ticket_roundtrip_all_fields() {
let (store, _tmp) = open_test_store().await;
let ws = crate::workspace::test_ws_named("/test_ws", "test_workspace");
let id = store
.create_ticket(
"Roundtrip Title",
"Roundtrip description",
&ws,
TicketPhase::Backlog,
&[],
"test_reporter",
None,
)
.await
.expect("create_ticket");
store
.set_assigned_to(&id, Some("test_assignee"))
.await
.expect("set_assigned_to");
store
.set_commit_info(&id, "abcdef0123456789abcdef0123456789abcd0123", 42, 7)
.await
.expect("set_commit_info");
let ticket = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists");
assert_eq!(ticket.id, id, "id mismatch");
assert_eq!(ticket.title, "Roundtrip Title", "title mismatch");
assert_eq!(
ticket.description, "Roundtrip description",
"description mismatch",
);
assert_eq!(ticket.status, TicketPhase::Backlog, "status mismatch");
assert_eq!(
ticket.assigned_to.as_deref(),
Some("test_assignee"),
"assigned_to should round-trip",
);
assert_eq!(ticket.workspace_name, "test_workspace");
assert!(
ticket.created_at.contains('T'),
"created_at should be RFC 3339: {}",
ticket.created_at,
);
assert!(
ticket.updated_at.contains('T'),
"updated_at should be RFC 3339: {}",
ticket.updated_at,
);
assert!(ticket.comments.is_empty(), "no comments expected");
assert!(
ticket.prerequisites.is_empty(),
"prerequisites should round-trip as empty",
);
assert_eq!(
ticket.commit_hash.as_deref(),
Some("abcdef0123456789abcdef0123456789abcd0123"),
"commit_hash mismatch",
);
assert_eq!(ticket.lines_added, Some(42), "lines_added mismatch");
assert_eq!(ticket.lines_removed, Some(7), "lines_removed mismatch");
assert_eq!(ticket.reporter, "test_reporter", "reporter mismatch");
assert!(
ticket.supersedes.is_none(),
"supersedes should be None for simple ticket",
);
assert!(
ticket.superseded_by.is_none(),
"superseded_by should be None for simple ticket",
);
assert!(
!ticket.is_archived,
"is_archived should be false before archiving",
);
store.set_archived(&id).await.expect("set_archived");
let archived = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists after archive");
assert!(
archived.is_archived,
"is_archived should be true after set_archived"
);
assert!(
archived.assigned_to.is_none(),
"assigned_to should be cleared after archive",
);
}
async fn create_archived_ticket(
store: &super::BoardStore,
title: &str,
workspace_name: &str,
) -> String {
let ws = test_ws(workspace_name);
let id = store
.create_ticket(
title,
"desc",
&ws,
crate::board::TicketPhase::Done,
&[],
"test",
None,
)
.await
.expect("create_ticket");
store.set_archived(&id).await.expect("set_archived");
id
}
#[tokio::test]
async fn test_search_archived_by_fts_finds_matching_title() {
let (store, _tmp) = open_test_store().await;
let id = create_archived_ticket(&store, "Fix network timeout bug", "ws1").await;
let results = store
.search_archived_by_fts("network timeout", 10)
.await
.expect("FTS search");
assert!(!results.is_empty(), "should find the ticket");
let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
assert!(
ids.contains(&id.as_str()),
"result should contain our ticket"
);
}
#[tokio::test]
async fn test_search_archived_by_fts_excludes_non_archived() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws("ws2");
store
.create_ticket(
"Still active",
"desc",
&ws,
crate::board::TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create_ticket");
let results = store
.search_archived_by_fts("active", 10)
.await
.expect("FTS search");
assert!(results.is_empty(), "non-archived ticket should not appear");
}
#[tokio::test]
async fn test_search_archived_by_fts_sanitize_mangles_query() {
let (store, _tmp) = open_test_store().await;
let results = store
.search_archived_by_fts("!@#$%", 10)
.await
.expect("FTS search");
assert!(
results.is_empty(),
"query with only special chars becomes empty after sanitize"
);
}
#[tokio::test]
async fn test_list_archived_with_embeddings_empty_when_no_tickets() {
let (store, _tmp) = open_test_store().await;
let candidates = store.list_archived_with_embeddings().await.expect("list");
assert!(candidates.is_empty(), "no tickets at all");
}
#[tokio::test]
async fn test_list_tickets_minimal() {
let (store, _tmp) = open_test_store().await;
let id_a = create_archived_ticket(&store, "Alpha", "ws").await;
let id_b = create_archived_ticket(&store, "Beta", "ws").await;
let id_c = create_archived_ticket(&store, "Gamma", "ws").await;
let rows = store
.list_tickets_minimal(std::slice::from_ref(&id_a))
.await
.expect("list minimal");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, id_a);
assert_eq!(rows[0].1, "Alpha");
assert_eq!(rows[0].2, "done");
let rows = store
.list_tickets_minimal(&[id_a.clone(), "nonexistent".to_string()])
.await
.expect("list minimal");
assert_eq!(rows.len(), 1, "nonexistent IDs should be omitted");
assert_eq!(rows[0].0, id_a);
let rows: Vec<(String, String, String)> = store
.list_tickets_minimal(&[] as &[String])
.await
.expect("list minimal");
assert!(rows.is_empty(), "empty ids should return empty results");
let rows = store
.list_tickets_minimal(&[id_c.clone(), id_a.clone(), id_b.clone()])
.await
.expect("list minimal");
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].0, id_c, "first result should be Gamma");
assert_eq!(rows[1].0, id_a, "second result should be Alpha");
assert_eq!(rows[2].0, id_b, "third result should be Beta");
}
#[tokio::test]
async fn test_detailed_display_basic() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/test-workspace", "test-ws");
let prereq_id = store
.create_ticket(
"Prereq",
"prereq desc",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create prereq");
let id = store
.create_ticket(
"Display Test Ticket",
"A description for testing",
&ws,
TicketPhase::InDevelopment,
std::slice::from_ref(&prereq_id),
"manager",
None,
)
.await
.expect("create");
let ticket = store.get_ticket(&id).await.expect("get").expect("exists");
let display = ticket.detailed_display();
assert!(
display.contains(&format!("Ticket: {id}")),
"should contain ticket id"
);
assert!(
display.contains("Title: Display Test Ticket"),
"should contain title"
);
assert!(
display.contains("Description: A description for testing"),
"should contain description"
);
assert!(
display.contains("Status: in_development"),
"should use snake_case status"
);
assert!(
display.contains("Reporter: manager"),
"should contain reporter"
);
assert!(
display.contains("Workspace: test-ws"),
"should contain workspace"
);
assert!(
display.contains("Created:"),
"should contain created timestamp"
);
assert!(
display.contains("Updated:"),
"should contain updated timestamp"
);
assert!(
display.contains(&format!("Prerequisites: {prereq_id}")),
"should show prerequisites"
);
assert!(
display.contains("Comments:"),
"should have comments section"
);
assert!(display.contains("(no comments)"), "should show no comments");
assert!(
!display.contains("Supersedes:"),
"no supersedes when not set"
);
assert!(
!display.contains("Superseded by:"),
"no superseded_by when not set"
);
assert!(
!display.contains("Archived:"),
"no archived line when false"
);
assert!(
!display.contains("assigned_to:"),
"assigned_to should not be displayed"
);
assert!(
!display.contains("commit_hash:"),
"commit_hash should not be displayed"
);
assert!(
!display.contains("lines_added:"),
"lines_added should not be displayed"
);
assert!(
!display.contains("lines_removed:"),
"lines_removed should not be displayed"
);
}
#[allow(clippy::too_many_lines)]
#[tokio::test]
async fn test_detailed_display_with_content() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/test-workspace", "test-ws");
let id = store
.create_ticket(
"Comment Test",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
store
.add_comment(&id, Role::Analyst.as_str(), "First comment")
.await
.expect("add_comment");
store
.add_comment(&id, Role::Reviewer.as_str(), "Second comment")
.await
.expect("add_comment");
let ticket = store.get_ticket(&id).await.expect("get").expect("exists");
let display = ticket.detailed_display();
assert!(
display.contains("Comments:"),
"should have comments section"
);
assert!(display.contains("[analyst]"), "should show analyst role");
assert!(display.contains("[reviewer]"), "should show reviewer role");
assert!(
display.contains("First comment"),
"should show first comment"
);
assert!(
display.contains("Second comment"),
"should show second comment"
);
assert!(
!display.contains("(no comments)"),
"should not say 'no comments' when comments exist"
);
let pre_a = store
.create_ticket(
"Pre-A",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create pre-a");
let pre_b = store
.create_ticket(
"Pre-B",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create pre-b");
let pre_c = store
.create_ticket(
"Pre-C",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create pre-c");
let multi_id = store
.create_ticket(
"Multi prereq",
"desc",
&ws,
DEFAULT_TICKET_PHASE,
&[pre_a.clone(), pre_b.clone(), pre_c.clone()],
"test",
None,
)
.await
.expect("create");
let ticket = store
.get_ticket(&multi_id)
.await
.expect("get")
.expect("exists");
let display = ticket.detailed_display();
assert!(
display.contains(&format!("Prerequisites: {pre_a}, {pre_b}, {pre_c}")),
"should show all prerequisites joined with comma+space"
);
}
#[tokio::test]
async fn test_detailed_display_supersedes_chain() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws_named("/ws", "ws");
let old_id = store
.create_ticket(
"Old ticket",
"old desc",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create old");
let new_id = store
.supersede_and_create(&old_id, "New ticket", "new desc", &ws, &[], "test", None)
.await
.expect("supersede");
let new_ticket = store
.get_ticket(&new_id)
.await
.expect("get")
.expect("exists");
let new_display = new_ticket.detailed_display();
assert!(
new_display.contains(&format!("Supersedes: {old_id}")),
"new ticket should show Supersedes: old_id"
);
let old_ticket = store
.get_ticket(&old_id)
.await
.expect("get")
.expect("exists");
let old_display = old_ticket.detailed_display();
assert!(
old_display.contains(&format!("Superseded by: {new_id}")),
"old ticket should show Superseded by: new_id"
);
assert!(
old_display.contains("Archived: yes"),
"old ticket should be archived"
);
}
#[tokio::test]
async fn test_list_archived_with_embeddings_returns_deserialized() {
let (store, _tmp) = open_test_store().await;
let ws = test_ws("ws");
let embedding: Vec<f32> = vec![1.0, 2.0];
let blob: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
let id = store
.create_ticket(
"Embedded ticket",
"desc",
&ws,
crate::board::TicketPhase::Done,
&[],
"test",
Some(&blob),
)
.await
.expect("create_ticket with embedding");
store.set_archived(&id).await.expect("archive");
let candidates = store.list_archived_with_embeddings().await.expect("list");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].0, id);
assert_eq!(candidates[0].1, vec![1.0, 2.0]);
}
}