orchai/src-tauri/src/models/worktree.rs

250 lines
8.6 KiB
Rust

use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Worktree {
pub id: String,
pub ticket_id: String,
pub path: String,
pub branch_name: String,
pub status: String,
pub created_at: String,
pub merged_at: Option<String>,
pub merged_into: Option<String>,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Worktree> {
Ok(Worktree {
id: row.get(0)?,
ticket_id: row.get(1)?,
path: row.get(2)?,
branch_name: row.get(3)?,
status: row.get(4)?,
created_at: row.get(5)?,
merged_at: row.get(6)?,
merged_into: row.get(7)?,
})
}
const SELECT_ALL_COLS: &str = "SELECT id, ticket_id, path, branch_name, status, \
created_at, merged_at, merged_into FROM worktrees";
impl Worktree {
pub fn insert(
conn: &Connection,
ticket_id: &str,
path: &str,
branch_name: &str,
) -> Result<Worktree> {
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO worktrees (id, ticket_id, path, branch_name, status, created_at) \
VALUES (?1, ?2, ?3, ?4, 'Active', ?5)",
params![id, ticket_id, path, branch_name, now],
)?;
Ok(Worktree {
id,
ticket_id: ticket_id.to_string(),
path: path.to_string(),
branch_name: branch_name.to_string(),
status: "Active".to_string(),
created_at: now,
merged_at: None,
merged_into: None,
})
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Worktree> {
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
conn.query_row(&sql, params![id], from_row)
}
pub fn get_by_ticket_id(conn: &Connection, ticket_id: &str) -> Result<Option<Worktree>> {
let sql = format!("{} WHERE ticket_id = ?1", SELECT_ALL_COLS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query_map(params![ticket_id], from_row)?;
match rows.next() {
Some(Ok(w)) => Ok(Some(w)),
Some(Err(e)) => Err(e),
None => Ok(None),
}
}
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<Worktree>> {
let sql = "SELECT w.id, w.ticket_id, w.path, w.branch_name, w.status, \
w.created_at, w.merged_at, w.merged_into \
FROM worktrees w \
JOIN processed_tickets pt ON w.ticket_id = pt.id \
WHERE pt.project_id = ?1 \
ORDER BY w.created_at DESC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![project_id], from_row)?;
rows.collect()
}
pub fn set_merged(conn: &Connection, id: &str, target_branch: &str) -> Result<()> {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE worktrees SET status = 'Merged', merged_at = ?1, merged_into = ?2 WHERE id = ?3",
params![now, target_branch, id],
)?;
Ok(())
}
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
conn.execute("DELETE FROM worktrees WHERE id = ?1", params![id])?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::models::agent::{Agent, AgentRole, AgentTool};
use crate::models::project::Project;
use crate::models::ticket::ProcessedTicket;
use crate::models::tracker::{NewWatchedTracker, WatchedTracker};
fn setup() -> (Connection, String) {
let conn = db::init_in_memory().expect("db init");
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
let developer =
Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();
let tracker = WatchedTracker::insert(
&conn,
NewWatchedTracker {
project_id: project.id.clone(),
tracker_id: 100,
tracker_label: "Bugs".to_string(),
polling_interval: 10,
analyst_agent_id: analyst.id.clone(),
developer_agent_id: developer.id.clone(),
filters: vec![],
},
)
.unwrap();
let ticket =
ProcessedTicket::insert_if_new(&conn, &project.id, &tracker.id, 42, "Bug 42", "{}")
.unwrap()
.unwrap();
(conn, ticket.id)
}
#[test]
fn test_insert_and_get_by_id() {
let (conn, ticket_id) = setup();
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/orchai-42", "orchai/42").unwrap();
assert_eq!(wt.status, "Active");
assert_eq!(wt.branch_name, "orchai/42");
let found = Worktree::get_by_id(&conn, &wt.id).unwrap();
assert_eq!(found.id, wt.id);
assert_eq!(found.ticket_id, ticket_id);
assert_eq!(found.path, "/tmp/orchai-42");
}
#[test]
fn test_get_by_ticket_id() {
let (conn, ticket_id) = setup();
let none = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap();
assert!(none.is_none());
Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
let some = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap();
assert!(some.is_some());
assert_eq!(some.unwrap().ticket_id, ticket_id);
}
#[test]
fn test_list_by_project() {
let conn = db::init_in_memory().expect("db init");
let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap();
let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
let developer =
Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();
let tracker = WatchedTracker::insert(
&conn,
NewWatchedTracker {
project_id: project.id.clone(),
tracker_id: 100,
tracker_label: "Bugs".to_string(),
polling_interval: 10,
analyst_agent_id: analyst.id.clone(),
developer_agent_id: developer.id.clone(),
filters: vec![],
},
)
.unwrap();
let t1 = ProcessedTicket::insert_if_new(&conn, &project.id, &tracker.id, 1, "T1", "{}")
.unwrap()
.unwrap();
let t2 = ProcessedTicket::insert_if_new(&conn, &project.id, &tracker.id, 2, "T2", "{}")
.unwrap()
.unwrap();
Worktree::insert(&conn, &t1.id, "/wt1", "orchai/1").unwrap();
Worktree::insert(&conn, &t2.id, "/wt2", "orchai/2").unwrap();
let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap();
assert_eq!(worktrees.len(), 2);
}
#[test]
fn test_list_by_project_includes_external_source_worktrees() {
let conn = db::init_in_memory().expect("db init");
let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap();
let ticket = ProcessedTicket::insert_external(
&conn,
&project.id,
"graylog",
Some("subject-1"),
-101,
"Graylog subject",
"{}",
)
.expect("external ticket insert should succeed");
Worktree::insert(&conn, &ticket.id, "/wt-graylog", "orchai/graylog-101")
.expect("worktree insert should succeed");
let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap();
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].ticket_id, ticket.id);
}
#[test]
fn test_set_merged() {
let (conn, ticket_id) = setup();
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
Worktree::set_merged(&conn, &wt.id, "feature/login").unwrap();
let updated = Worktree::get_by_id(&conn, &wt.id).unwrap();
assert_eq!(updated.status, "Merged");
assert_eq!(updated.merged_into.unwrap(), "feature/login");
let merged_at = updated
.merged_at
.as_deref()
.expect("merged_at should be set");
assert!(chrono::DateTime::parse_from_rfc3339(merged_at).is_ok());
}
#[test]
fn test_delete() {
let (conn, ticket_id) = setup();
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
Worktree::delete(&conn, &wt.id).unwrap();
let result = Worktree::get_by_id(&conn, &wt.id);
assert!(result.is_err());
}
}