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 credential;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod ticket;
|
||||||
pub mod tracker;
|
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