feat: ProcessedTicket model with deduplication for new ticket detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e1356ba1b
commit
8f7da7b581
2 changed files with 240 additions and 0 deletions
|
|
@ -1,3 +1,4 @@
|
|||
pub mod credential;
|
||||
pub mod project;
|
||||
pub mod ticket;
|
||||
pub mod tracker;
|
||||
|
|
|
|||
239
src-tauri/src/models/ticket.rs
Normal file
239
src-tauri/src/models/ticket.rs
Normal file
|
|
@ -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<String>,
|
||||
pub developer_report: Option<String>,
|
||||
pub worktree_path: Option<String>,
|
||||
pub branch_name: Option<String>,
|
||||
pub detected_at: String,
|
||||
pub processed_at: Option<String>,
|
||||
}
|
||||
|
||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
|
||||
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<Option<ProcessedTicket>> {
|
||||
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<bool> {
|
||||
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<Vec<ProcessedTicket>> {
|
||||
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<Vec<ProcessedTicket>> {
|
||||
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<ProcessedTicket> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue