diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index ef2d98b..5caa5db 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod credential; pub mod project; +pub mod ticket; pub mod tracker; diff --git a/src-tauri/src/models/ticket.rs b/src-tauri/src/models/ticket.rs new file mode 100644 index 0000000..d321f9b --- /dev/null +++ b/src-tauri/src/models/ticket.rs @@ -0,0 +1,239 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessedTicket { + pub id: String, + pub tracker_id: String, + pub artifact_id: i32, + pub artifact_title: String, + pub artifact_data: String, + pub status: String, + pub analyst_report: Option, + pub developer_report: Option, + pub worktree_path: Option, + pub branch_name: Option, + pub detected_at: String, + pub processed_at: Option, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(ProcessedTicket { + id: row.get(0)?, + tracker_id: row.get(1)?, + artifact_id: row.get(2)?, + artifact_title: row.get(3)?, + artifact_data: row.get(4)?, + status: row.get(5)?, + analyst_report: row.get(6)?, + developer_report: row.get(7)?, + worktree_path: row.get(8)?, + branch_name: row.get(9)?, + detected_at: row.get(10)?, + processed_at: row.get(11)?, + }) +} + +const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, artifact_id, artifact_title, artifact_data, \ + status, analyst_report, developer_report, worktree_path, branch_name, \ + detected_at, processed_at FROM processed_tickets"; + +impl ProcessedTicket { + /// Insert a new ticket if one with the same (tracker_id, artifact_id) doesn't exist. + /// Returns Some(ticket) if inserted, None if it was a duplicate. + pub fn insert_if_new( + conn: &Connection, + tracker_id: &str, + artifact_id: i32, + artifact_title: &str, + artifact_data: &str, + ) -> Result> { + if Self::exists(conn, tracker_id, artifact_id)? { + return Ok(None); + } + + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO processed_tickets \ + (id, tracker_id, artifact_id, artifact_title, artifact_data, status, detected_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, 'Pending', ?6)", + params![id, tracker_id, artifact_id, artifact_title, artifact_data, now], + )?; + + let ticket = ProcessedTicket { + id, + tracker_id: tracker_id.to_string(), + artifact_id, + artifact_title: artifact_title.to_string(), + artifact_data: artifact_data.to_string(), + status: "Pending".to_string(), + analyst_report: None, + developer_report: None, + worktree_path: None, + branch_name: None, + detected_at: now, + processed_at: None, + }; + + Ok(Some(ticket)) + } + + /// Returns true if a ticket with (tracker_id, artifact_id) already exists. + pub fn exists(conn: &Connection, tracker_id: &str, artifact_id: i32) -> Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM processed_tickets WHERE tracker_id = ?1 AND artifact_id = ?2", + params![tracker_id, artifact_id], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn list_by_tracker(conn: &Connection, tracker_id: &str) -> Result> { + let sql = format!( + "{} WHERE tracker_id = ?1 ORDER BY detected_at DESC", + SELECT_ALL_COLS + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![tracker_id], from_row)?; + rows.collect() + } + + pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT pt.id, pt.tracker_id, pt.artifact_id, pt.artifact_title, pt.artifact_data, \ + pt.status, pt.analyst_report, pt.developer_report, pt.worktree_path, pt.branch_name, \ + pt.detected_at, pt.processed_at \ + FROM processed_tickets pt \ + JOIN watched_trackers wt ON pt.tracker_id = wt.id \ + WHERE wt.project_id = ?1 \ + ORDER BY pt.detected_at DESC", + )?; + let rows = stmt.query_map(params![project_id], from_row)?; + rows.collect() + } + + pub fn get_by_id(conn: &Connection, id: &str) -> Result { + let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS); + conn.query_row(&sql, params![id], from_row) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::project::Project; + use crate::models::tracker::{AgentConfig, WatchedTracker}; + + fn setup() -> (Connection, String) { + let conn = db::init_in_memory().expect("db init should succeed"); + let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap(); + let agent_config = AgentConfig { + analyst_command: "claude".into(), + analyst_args: vec![], + developer_command: "claude".into(), + developer_args: vec![], + }; + let tracker = + WatchedTracker::insert(&conn, &project.id, 456, "Bugs", 10, agent_config, vec![]) + .unwrap(); + (conn, tracker.id) + } + + #[test] + fn test_insert_if_new_creates_ticket() { + let (conn, tracker_id) = setup(); + + let result = ProcessedTicket::insert_if_new( + &conn, + &tracker_id, + 101, + "Fix login bug", + "{\"id\": 101}", + ) + .expect("insert_if_new should succeed"); + + assert!(result.is_some()); + let ticket = result.unwrap(); + assert_eq!(ticket.status, "Pending"); + assert_eq!(ticket.tracker_id, tracker_id); + assert_eq!(ticket.artifact_id, 101); + assert_eq!(ticket.artifact_title, "Fix login bug"); + assert!(ticket.analyst_report.is_none()); + assert!(ticket.processed_at.is_none()); + } + + #[test] + fn test_insert_if_new_returns_none_for_duplicate() { + let (conn, tracker_id) = setup(); + + let first = ProcessedTicket::insert_if_new( + &conn, + &tracker_id, + 202, + "Crash on startup", + "{\"id\": 202}", + ) + .expect("first insert should succeed"); + assert!(first.is_some()); + + let second = ProcessedTicket::insert_if_new( + &conn, + &tracker_id, + 202, + "Crash on startup", + "{\"id\": 202}", + ) + .expect("second insert_if_new should succeed"); + assert!(second.is_none()); + } + + #[test] + fn test_exists() { + let (conn, tracker_id) = setup(); + + let before = ProcessedTicket::exists(&conn, &tracker_id, 303) + .expect("exists check should succeed"); + assert!(!before); + + ProcessedTicket::insert_if_new(&conn, &tracker_id, 303, "Some ticket", "{}") + .expect("insert should succeed"); + + let after = ProcessedTicket::exists(&conn, &tracker_id, 303) + .expect("exists check after insert should succeed"); + assert!(after); + } + + #[test] + fn test_list_by_tracker() { + let (conn, tracker_id) = setup(); + + ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "Ticket One", "{}").unwrap(); + ProcessedTicket::insert_if_new(&conn, &tracker_id, 2, "Ticket Two", "{}").unwrap(); + + let tickets = + ProcessedTicket::list_by_tracker(&conn, &tracker_id).expect("list should succeed"); + assert_eq!(tickets.len(), 2); + } + + #[test] + fn test_get_by_id() { + let (conn, tracker_id) = setup(); + + let inserted = + ProcessedTicket::insert_if_new(&conn, &tracker_id, 404, "Not Found Bug", "{\"id\": 404}") + .expect("insert should succeed") + .expect("should be Some"); + + let found = + ProcessedTicket::get_by_id(&conn, &inserted.id).expect("get_by_id should succeed"); + + assert_eq!(found.id, inserted.id); + assert_eq!(found.artifact_id, 404); + assert_eq!(found.artifact_title, "Not Found Bug"); + assert_eq!(found.status, "Pending"); + } +}