feat: WatchedTracker model with CRUD, filters, and agent config

This commit is contained in:
thibaud-leclere 2026-04-13 14:30:58 +02:00
parent 73f6909be4
commit 1e69cf16a9
2 changed files with 360 additions and 0 deletions

View file

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

View file

@ -0,0 +1,359 @@
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub analyst_command: String,
pub analyst_args: Vec<String>,
pub developer_command: String,
pub developer_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterGroup {
pub conditions: Vec<Filter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Filter {
pub field: String,
pub operator: String, // "In", "NotIn", "Equals", "NotEquals"
pub value: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchedTracker {
pub id: String,
pub project_id: String,
pub tracker_id: i32,
pub tracker_label: String,
pub polling_interval: i32,
pub agent_config: AgentConfig,
pub filters: Vec<FilterGroup>,
pub enabled: bool,
pub last_polled_at: Option<String>,
pub created_at: String,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
let agent_config_json: String = row.get(5)?;
let filters_json: String = row.get(6)?;
let enabled_int: i32 = row.get(7)?;
let agent_config: AgentConfig = serde_json::from_str(&agent_config_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e)))?;
let filters: Vec<FilterGroup> = serde_json::from_str(&filters_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e)))?;
Ok(WatchedTracker {
id: row.get(0)?,
project_id: row.get(1)?,
tracker_id: row.get(2)?,
tracker_label: row.get(3)?,
polling_interval: row.get(4)?,
agent_config,
filters,
enabled: enabled_int != 0,
last_polled_at: row.get(8)?,
created_at: row.get(9)?,
})
}
impl WatchedTracker {
pub fn insert(
conn: &Connection,
project_id: &str,
tracker_id: i32,
tracker_label: &str,
polling_interval: i32,
agent_config: AgentConfig,
filters: Vec<FilterGroup>,
) -> Result<WatchedTracker> {
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let agent_config_json = serde_json::to_string(&agent_config)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let filters_json = serde_json::to_string(&filters)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
conn.execute(
"INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, now],
)?;
Ok(WatchedTracker {
id,
project_id: project_id.to_string(),
tracker_id,
tracker_label: tracker_label.to_string(),
polling_interval,
agent_config,
filters,
enabled: true,
last_polled_at: None,
created_at: now,
})
}
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<WatchedTracker>> {
let mut stmt = conn.prepare(
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \
FROM watched_trackers WHERE project_id = ?1 ORDER BY created_at DESC",
)?;
let rows = stmt.query_map(params![project_id], from_row)?;
rows.collect()
}
pub fn list_all_enabled(conn: &Connection) -> Result<Vec<WatchedTracker>> {
let mut stmt = conn.prepare(
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \
FROM watched_trackers WHERE enabled = 1 ORDER BY created_at DESC",
)?;
let rows = stmt.query_map([], from_row)?;
rows.collect()
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<WatchedTracker> {
conn.query_row(
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \
FROM watched_trackers WHERE id = ?1",
params![id],
from_row,
)
}
pub fn update(
conn: &Connection,
id: &str,
polling_interval: i32,
agent_config: AgentConfig,
filters: Vec<FilterGroup>,
enabled: bool,
) -> Result<()> {
let agent_config_json = serde_json::to_string(&agent_config)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let filters_json = serde_json::to_string(&filters)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let enabled_int = if enabled { 1i32 } else { 0i32 };
let affected = conn.execute(
"UPDATE watched_trackers SET polling_interval = ?1, agent_config_json = ?2, filters_json = ?3, enabled = ?4 WHERE id = ?5",
params![polling_interval, agent_config_json, filters_json, enabled_int, id],
)?;
if affected == 0 {
return Err(rusqlite::Error::QueryReturnedNoRows);
}
Ok(())
}
pub fn update_last_polled(conn: &Connection, id: &str) -> Result<()> {
let now = chrono::Utc::now().to_rfc3339();
let affected = conn.execute(
"UPDATE watched_trackers SET last_polled_at = ?1 WHERE id = ?2",
params![now, id],
)?;
if affected == 0 {
return Err(rusqlite::Error::QueryReturnedNoRows);
}
Ok(())
}
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
let affected = conn.execute("DELETE FROM watched_trackers WHERE id = ?1", params![id])?;
if affected == 0 {
return Err(rusqlite::Error::QueryReturnedNoRows);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::models::project::Project;
fn setup() -> Connection {
let conn = db::init_in_memory().expect("db init should succeed");
Project::insert(&conn, "Test Project", "/path/test", None, "main").unwrap();
conn
}
fn project_id(conn: &Connection) -> String {
Project::list(conn).unwrap().into_iter().next().unwrap().id
}
fn sample_agent_config() -> AgentConfig {
AgentConfig {
analyst_command: "analyst".to_string(),
analyst_args: vec!["--mode".to_string(), "analyze".to_string()],
developer_command: "developer".to_string(),
developer_args: vec!["--fix".to_string()],
}
}
fn sample_filters() -> Vec<FilterGroup> {
vec![FilterGroup {
conditions: vec![Filter {
field: "status".to_string(),
operator: "In".to_string(),
value: vec!["Open".to_string(), "In Progress".to_string()],
}],
}]
}
#[test]
fn test_insert_tracker() {
let conn = setup();
let pid = project_id(&conn);
let tracker = WatchedTracker::insert(
&conn,
&pid,
42,
"Bug Tracker",
15,
sample_agent_config(),
sample_filters(),
)
.expect("insert should succeed");
assert!(!tracker.id.is_empty());
assert_eq!(tracker.project_id, pid);
assert_eq!(tracker.tracker_id, 42);
assert_eq!(tracker.tracker_label, "Bug Tracker");
assert_eq!(tracker.polling_interval, 15);
assert!(tracker.enabled);
assert!(tracker.last_polled_at.is_none());
assert!(!tracker.created_at.is_empty());
assert_eq!(tracker.agent_config.analyst_command, "analyst");
assert_eq!(tracker.filters.len(), 1);
}
#[test]
fn test_list_by_project() {
let conn = setup();
let pid = project_id(&conn);
WatchedTracker::insert(&conn, &pid, 1, "Tracker A", 10, sample_agent_config(), vec![]).unwrap();
WatchedTracker::insert(&conn, &pid, 2, "Tracker B", 20, sample_agent_config(), vec![]).unwrap();
let trackers = WatchedTracker::list_by_project(&conn, &pid).expect("list should succeed");
assert_eq!(trackers.len(), 2);
}
#[test]
fn test_list_all_enabled() {
let conn = setup();
let pid = project_id(&conn);
let t1 = WatchedTracker::insert(&conn, &pid, 1, "Enabled", 10, sample_agent_config(), vec![]).unwrap();
let t2 = WatchedTracker::insert(&conn, &pid, 2, "Disabled", 10, sample_agent_config(), vec![]).unwrap();
// Disable t2
WatchedTracker::update(
&conn,
&t2.id,
t2.polling_interval,
sample_agent_config(),
vec![],
false,
)
.unwrap();
let enabled = WatchedTracker::list_all_enabled(&conn).expect("list_all_enabled should succeed");
assert_eq!(enabled.len(), 1);
assert_eq!(enabled[0].id, t1.id);
}
#[test]
fn test_get_by_id() {
let conn = setup();
let pid = project_id(&conn);
let created = WatchedTracker::insert(
&conn,
&pid,
99,
"My Tracker",
30,
sample_agent_config(),
sample_filters(),
)
.unwrap();
let found = WatchedTracker::get_by_id(&conn, &created.id).expect("get_by_id should succeed");
assert_eq!(found.id, created.id);
assert_eq!(found.tracker_id, 99);
assert_eq!(found.tracker_label, "My Tracker");
assert_eq!(found.polling_interval, 30);
assert_eq!(found.filters.len(), 1);
}
#[test]
fn test_update_tracker() {
let conn = setup();
let pid = project_id(&conn);
let created = WatchedTracker::insert(
&conn,
&pid,
10,
"Original",
5,
sample_agent_config(),
sample_filters(),
)
.unwrap();
let new_filters = vec![FilterGroup {
conditions: vec![Filter {
field: "priority".to_string(),
operator: "Equals".to_string(),
value: vec!["High".to_string()],
}],
}];
WatchedTracker::update(&conn, &created.id, 60, sample_agent_config(), new_filters, false)
.expect("update should succeed");
let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap();
assert_eq!(updated.polling_interval, 60);
assert!(!updated.enabled);
assert_eq!(updated.filters[0].conditions[0].field, "priority");
}
#[test]
fn test_update_last_polled() {
let conn = setup();
let pid = project_id(&conn);
let created =
WatchedTracker::insert(&conn, &pid, 5, "Poller", 10, sample_agent_config(), vec![]).unwrap();
assert!(created.last_polled_at.is_none());
WatchedTracker::update_last_polled(&conn, &created.id).expect("update_last_polled should succeed");
let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap();
assert!(updated.last_polled_at.is_some());
}
#[test]
fn test_delete_tracker() {
let conn = setup();
let pid = project_id(&conn);
let created =
WatchedTracker::insert(&conn, &pid, 7, "ToDelete", 10, sample_agent_config(), vec![]).unwrap();
WatchedTracker::delete(&conn, &created.id).expect("delete should succeed");
let result = WatchedTracker::get_by_id(&conn, &created.id);
assert!(result.is_err());
}
}