feat: ProcessedTicket model with deduplication for new ticket detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere 2026-04-13 14:35:13 +02:00
parent 6e1356ba1b
commit 8f7da7b581
2 changed files with 240 additions and 0 deletions

View file

@ -1,3 +1,4 @@
pub mod credential;
pub mod project;
pub mod ticket;
pub mod tracker;

View 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");
}
}