diff --git a/src-tauri/migrations/011_add_review_step.sql b/src-tauri/migrations/011_add_review_step.sql new file mode 100644 index 0000000..5e73b7a --- /dev/null +++ b/src-tauri/migrations/011_add_review_step.sql @@ -0,0 +1,33 @@ +INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at) +SELECT + 'default-reviewer-agent', + 'Default Reviewer', + 'reviewer', + 'codex', + '', + 1, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE NOT EXISTS ( + SELECT 1 FROM agents WHERE role = 'reviewer' AND is_default = 1 +); + +ALTER TABLE watched_trackers ADD COLUMN reviewer_agent_id TEXT REFERENCES agents(id); + +UPDATE watched_trackers +SET reviewer_agent_id = 'default-reviewer-agent' +WHERE reviewer_agent_id IS NULL OR TRIM(reviewer_agent_id) = ''; + +UPDATE watched_trackers +SET status = CASE + WHEN analyst_agent_id IS NULL OR developer_agent_id IS NULL OR reviewer_agent_id IS NULL THEN 'invalid' + ELSE 'valid' +END; + +ALTER TABLE graylog_credentials ADD COLUMN reviewer_agent_id TEXT REFERENCES agents(id); + +UPDATE graylog_credentials +SET reviewer_agent_id = 'default-reviewer-agent' +WHERE reviewer_agent_id IS NULL OR TRIM(reviewer_agent_id) = ''; + +ALTER TABLE processed_tickets ADD COLUMN review_report TEXT; diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 6aa4998..aaf5c48 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -11,6 +11,7 @@ fn agent_role_label(role: &AgentRole) -> &'static str { match role { AgentRole::Analyst => "analyste", AgentRole::Developer => "developpeur", + AgentRole::Reviewer => "reviewer", } } @@ -141,26 +142,21 @@ pub fn update_agent( Agent::update(&db, &id, &name, role.clone(), tool, &custom_prompt)?; if previous.role != role { - match role { - AgentRole::Analyst => { - db.execute( - "UPDATE watched_trackers - SET developer_agent_id = NULL, - status = 'invalid' - WHERE developer_agent_id = ?1", - params![id], - )?; - } - AgentRole::Developer => { - db.execute( - "UPDATE watched_trackers - SET analyst_agent_id = NULL, - status = 'invalid' - WHERE analyst_agent_id = ?1", - params![id], - )?; - } - } + db.execute( + "UPDATE watched_trackers + SET analyst_agent_id = CASE WHEN analyst_agent_id = ?1 THEN NULL ELSE analyst_agent_id END, + developer_agent_id = CASE WHEN developer_agent_id = ?1 THEN NULL ELSE developer_agent_id END, + reviewer_agent_id = CASE WHEN reviewer_agent_id = ?1 THEN NULL ELSE reviewer_agent_id END, + status = CASE + WHEN (CASE WHEN analyst_agent_id = ?1 THEN NULL ELSE analyst_agent_id END) IS NULL + OR (CASE WHEN developer_agent_id = ?1 THEN NULL ELSE developer_agent_id END) IS NULL + OR (CASE WHEN reviewer_agent_id = ?1 THEN NULL ELSE reviewer_agent_id END) IS NULL + THEN 'invalid' + ELSE 'valid' + END + WHERE analyst_agent_id = ?1 OR developer_agent_id = ?1 OR reviewer_agent_id = ?1", + params![id], + )?; } Ok(()) diff --git a/src-tauri/src/commands/graylog.rs b/src-tauri/src/commands/graylog.rs index ab2c329..d613a55 100644 --- a/src-tauri/src/commands/graylog.rs +++ b/src-tauri/src/commands/graylog.rs @@ -12,13 +12,15 @@ fn validate_input( base_url: &str, analyst_agent_id: &str, developer_agent_id: &str, + reviewer_agent_id: &str, polling_interval_minutes: i32, lookback_minutes: i32, score_threshold: i32, -) -> Result<(String, String, String), AppError> { +) -> Result<(String, String, String, String), AppError> { let base_url = base_url.trim().to_string(); let analyst_agent_id = analyst_agent_id.trim().to_string(); let developer_agent_id = developer_agent_id.trim().to_string(); + let reviewer_agent_id = reviewer_agent_id.trim().to_string(); if base_url.is_empty() { return Err(AppError::from("Graylog URL is required".to_string())); @@ -29,6 +31,9 @@ fn validate_input( if developer_agent_id.is_empty() { return Err(AppError::from("Developer agent is required".to_string())); } + if reviewer_agent_id.is_empty() { + return Err(AppError::from("Reviewer agent is required".to_string())); + } if polling_interval_minutes <= 0 { return Err(AppError::from( "Polling interval must be strictly positive".to_string(), @@ -45,7 +50,12 @@ fn validate_input( )); } - Ok((base_url, analyst_agent_id, developer_agent_id)) + Ok(( + base_url, + analyst_agent_id, + developer_agent_id, + reviewer_agent_id, + )) } #[tauri::command] @@ -57,16 +67,18 @@ pub fn set_graylog_credentials( api_token: String, analyst_agent_id: String, developer_agent_id: String, + reviewer_agent_id: String, stream_id: Option, query_filter: String, polling_interval_minutes: i32, lookback_minutes: i32, score_threshold: i32, ) -> Result { - let (base_url, analyst_agent_id, developer_agent_id) = validate_input( + let (base_url, analyst_agent_id, developer_agent_id, reviewer_agent_id) = validate_input( &base_url, &analyst_agent_id, &developer_agent_id, + &reviewer_agent_id, polling_interval_minutes, lookback_minutes, score_threshold, @@ -97,7 +109,11 @@ pub fn set_graylog_credentials( &token_encrypted, &analyst_agent_id, &developer_agent_id, - stream_id.as_deref().map(str::trim).filter(|v| !v.is_empty()), + &reviewer_agent_id, + stream_id + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()), query_filter.trim(), polling_interval_minutes, lookback_minutes, diff --git a/src-tauri/src/commands/orchestrator.rs b/src-tauri/src/commands/orchestrator.rs index 1c42c54..2cd4216 100644 --- a/src-tauri/src/commands/orchestrator.rs +++ b/src-tauri/src/commands/orchestrator.rs @@ -37,14 +37,15 @@ pub fn retry_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), ProcessedTicket::update_status(&conn, &ticket_id, "Pending")?; conn.execute( - "UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, \ + "UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, review_report = NULL, \ worktree_path = NULL, branch_name = NULL, processed_at = NULL WHERE id = ?1", rusqlite::params![ticket_id], )?; let cleanup_target = if let Some(wt) = Worktree::get_by_ticket_id(&conn, &ticket_id)? { if wt.status == "Active" { - let project = crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?; + let project = + crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?; Some((wt, project.path)) } else { Some((wt, String::new())) @@ -75,10 +76,7 @@ pub fn retry_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), } #[tauri::command] -pub async fn cancel_ticket( - state: State<'_, AppState>, - ticket_id: String, -) -> Result<(), AppError> { +pub async fn cancel_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), AppError> { { let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?; diff --git a/src-tauri/src/commands/poller.rs b/src-tauri/src/commands/poller.rs index 39ee86d..9f060d9 100644 --- a/src-tauri/src/commands/poller.rs +++ b/src-tauri/src/commands/poller.rs @@ -225,6 +225,7 @@ fn step_from_status(status: &str) -> &'static str { match status { "Developing" => "developer", "Analyzing" => "analyst", + "Reviewing" => "review", _ => "processing", } } @@ -250,7 +251,10 @@ pub fn get_runtime_activity( let mut agents_by_ticket_id: HashMap = HashMap::new(); for ticket in &project_tickets { - if ticket.status == "Analyzing" || ticket.status == "Developing" { + if ticket.status == "Analyzing" + || ticket.status == "Developing" + || ticket.status == "Reviewing" + { agents_by_ticket_id.insert( ticket.id.clone(), RuntimeActiveAgent { diff --git a/src-tauri/src/commands/tracker.rs b/src-tauri/src/commands/tracker.rs index 6749bdc..97fef11 100644 --- a/src-tauri/src/commands/tracker.rs +++ b/src-tauri/src/commands/tracker.rs @@ -58,6 +58,7 @@ pub struct AddTrackerPayload { pub polling_interval: i32, pub analyst_agent_id: String, pub developer_agent_id: String, + pub reviewer_agent_id: String, pub filters: Vec, } @@ -73,6 +74,7 @@ pub fn add_tracker( ensure_agent_role(&db, &payload.analyst_agent_id, AgentRole::Analyst)?; ensure_agent_role(&db, &payload.developer_agent_id, AgentRole::Developer)?; + ensure_agent_role(&db, &payload.reviewer_agent_id, AgentRole::Reviewer)?; let tracker = WatchedTracker::insert( &db, @@ -83,6 +85,7 @@ pub fn add_tracker( polling_interval: payload.polling_interval, analyst_agent_id: payload.analyst_agent_id, developer_agent_id: payload.developer_agent_id, + reviewer_agent_id: payload.reviewer_agent_id, filters: payload.filters, }, )?; @@ -117,6 +120,7 @@ pub fn update_tracker( ensure_agent_role(&db, &update.analyst_agent_id, AgentRole::Analyst)?; ensure_agent_role(&db, &update.developer_agent_id, AgentRole::Developer)?; + ensure_agent_role(&db, &update.reviewer_agent_id, AgentRole::Reviewer)?; WatchedTracker::update(&db, &id, update)?; Ok(()) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index d345baf..881fcb6 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -11,6 +11,7 @@ const MIGRATION_007: &str = include_str!("../migrations/007_normalize_timestamps const MIGRATION_008: &str = include_str!("../migrations/008_project_scoped_tuleap_credentials.sql"); const MIGRATION_009: &str = include_str!("../migrations/009_graylog_auto_resolve.sql"); const MIGRATION_010: &str = include_str!("../migrations/010_agent_runtime_status.sql"); +const MIGRATION_011: &str = include_str!("../migrations/011_add_review_step.sql"); pub fn init(db_path: &Path) -> Result { let conn = Connection::open(db_path)?; @@ -76,6 +77,10 @@ fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch(MIGRATION_010)?; conn.pragma_update(None, "user_version", 10)?; } + if version < 11 { + conn.execute_batch(MIGRATION_011)?; + conn.pragma_update(None, "user_version", 11)?; + } Ok(()) } @@ -134,7 +139,7 @@ mod tests { let version: i32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 10); + assert_eq!(version, 11); } #[test] @@ -155,9 +160,17 @@ mod tests { |row| row.get(0), ) .unwrap(); + let reviewer_defaults: i32 = conn + .query_row( + "SELECT COUNT(*) FROM agents WHERE role = 'reviewer' AND is_default = 1", + [], + |row| row.get(0), + ) + .unwrap(); assert_eq!(analyst_defaults, 1); assert_eq!(developer_defaults, 1); + assert_eq!(reviewer_defaults, 1); } #[test] diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index ae238ed..0578efc 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -4,12 +4,14 @@ use uuid::Uuid; pub const DEFAULT_ANALYST_AGENT_ID: &str = "default-analyst-agent"; pub const DEFAULT_DEVELOPER_AGENT_ID: &str = "default-developer-agent"; +pub const DEFAULT_REVIEWER_AGENT_ID: &str = "default-reviewer-agent"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentRole { Analyst, Developer, + Reviewer, } impl AgentRole { @@ -17,6 +19,7 @@ impl AgentRole { match self { AgentRole::Analyst => "analyst", AgentRole::Developer => "developer", + AgentRole::Reviewer => "reviewer", } } @@ -24,6 +27,7 @@ impl AgentRole { match value { "analyst" => Ok(AgentRole::Analyst), "developer" => Ok(AgentRole::Developer), + "reviewer" => Ok(AgentRole::Reviewer), _ => Err(rusqlite::Error::InvalidParameterName(format!( "Invalid agent role: {}", value @@ -216,6 +220,7 @@ impl Agent { let default_id = match role { AgentRole::Analyst => DEFAULT_ANALYST_AGENT_ID, AgentRole::Developer => DEFAULT_DEVELOPER_AGENT_ID, + AgentRole::Reviewer => DEFAULT_REVIEWER_AGENT_ID, }; conn.query_row( @@ -330,19 +335,37 @@ impl Agent { "UPDATE watched_trackers SET analyst_agent_id = ?1 WHERE analyst_agent_id = ?2", params![default_agent.id, id], )?; + conn.execute( + "UPDATE graylog_credentials SET analyst_agent_id = ?1 WHERE analyst_agent_id = ?2", + params![default_agent.id, id], + )?; } AgentRole::Developer => { conn.execute( "UPDATE watched_trackers SET developer_agent_id = ?1 WHERE developer_agent_id = ?2", params![default_agent.id, id], )?; + conn.execute( + "UPDATE graylog_credentials SET developer_agent_id = ?1 WHERE developer_agent_id = ?2", + params![default_agent.id, id], + )?; + } + AgentRole::Reviewer => { + conn.execute( + "UPDATE watched_trackers SET reviewer_agent_id = ?1 WHERE reviewer_agent_id = ?2", + params![default_agent.id, id], + )?; + conn.execute( + "UPDATE graylog_credentials SET reviewer_agent_id = ?1 WHERE reviewer_agent_id = ?2", + params![default_agent.id, id], + )?; } } conn.execute( "UPDATE watched_trackers SET status = CASE - WHEN analyst_agent_id IS NULL OR developer_agent_id IS NULL THEN 'invalid' + WHEN analyst_agent_id IS NULL OR developer_agent_id IS NULL OR reviewer_agent_id IS NULL THEN 'invalid' ELSE 'valid' END", [], @@ -373,11 +396,14 @@ mod tests { let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap(); let developer = Agent::get_default_by_role(&conn, AgentRole::Developer).unwrap(); + let reviewer = Agent::get_default_by_role(&conn, AgentRole::Reviewer).unwrap(); assert_eq!(analyst.id, DEFAULT_ANALYST_AGENT_ID); assert_eq!(developer.id, DEFAULT_DEVELOPER_AGENT_ID); + assert_eq!(reviewer.id, DEFAULT_REVIEWER_AGENT_ID); assert!(analyst.is_default); assert!(developer.is_default); + assert!(reviewer.is_default); } #[test] @@ -523,6 +549,7 @@ mod tests { let analyst_default = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap(); let developer_default = Agent::get_default_by_role(&conn, AgentRole::Developer).unwrap(); + let reviewer_default = Agent::get_default_by_role(&conn, AgentRole::Reviewer).unwrap(); let analyst = Agent::insert(&conn, "Analyst", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); @@ -536,6 +563,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer_default.id.clone(), + reviewer_agent_id: reviewer_default.id.clone(), filters: vec![], }, ) @@ -553,5 +581,9 @@ mod tests { reloaded.developer_agent_id.as_deref(), Some(developer_default.id.as_str()) ); + assert_eq!( + reloaded.reviewer_agent_id.as_deref(), + Some(reviewer_default.id.as_str()) + ); } } diff --git a/src-tauri/src/models/graylog.rs b/src-tauri/src/models/graylog.rs index 5939925..60d2780 100644 --- a/src-tauri/src/models/graylog.rs +++ b/src-tauri/src/models/graylog.rs @@ -10,6 +10,7 @@ pub struct GraylogCredentials { pub api_token_encrypted: String, pub analyst_agent_id: String, pub developer_agent_id: String, + pub reviewer_agent_id: String, pub stream_id: Option, pub query_filter: String, pub polling_interval_minutes: i32, @@ -26,6 +27,7 @@ pub struct GraylogCredentialsSafe { pub base_url: String, pub analyst_agent_id: String, pub developer_agent_id: String, + pub reviewer_agent_id: String, pub stream_id: Option, pub query_filter: String, pub polling_interval_minutes: i32, @@ -80,13 +82,14 @@ fn credentials_from_row(row: &rusqlite::Row) -> rusqlite::Result, query_filter: &str, polling_interval_minutes: i32, @@ -153,6 +157,7 @@ impl GraylogCredentials { api_token_encrypted, analyst_agent_id, developer_agent_id, + reviewer_agent_id, stream_id, query_filter, polling_interval_minutes, @@ -160,12 +165,13 @@ impl GraylogCredentials { score_threshold, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(project_id) DO UPDATE SET base_url = excluded.base_url, api_token_encrypted = excluded.api_token_encrypted, analyst_agent_id = excluded.analyst_agent_id, developer_agent_id = excluded.developer_agent_id, + reviewer_agent_id = excluded.reviewer_agent_id, stream_id = excluded.stream_id, query_filter = excluded.query_filter, polling_interval_minutes = excluded.polling_interval_minutes, @@ -179,6 +185,7 @@ impl GraylogCredentials { api_token_encrypted, analyst_agent_id, developer_agent_id, + reviewer_agent_id, stream_id, query_filter, polling_interval_minutes, @@ -204,6 +211,7 @@ impl GraylogCredentials { api_token_encrypted, analyst_agent_id, developer_agent_id, + reviewer_agent_id, stream_id, query_filter, polling_interval_minutes, @@ -229,6 +237,7 @@ impl GraylogCredentials { api_token_encrypted, analyst_agent_id, developer_agent_id, + reviewer_agent_id, stream_id, query_filter, polling_interval_minutes, @@ -259,6 +268,7 @@ impl GraylogCredentials { base_url: self.base_url.clone(), analyst_agent_id: self.analyst_agent_id.clone(), developer_agent_id: self.developer_agent_id.clone(), + reviewer_agent_id: self.reviewer_agent_id.clone(), stream_id: self.stream_id.clone(), query_filter: self.query_filter.clone(), polling_interval_minutes: self.polling_interval_minutes, @@ -567,7 +577,7 @@ mod tests { use crate::models::ticket::ProcessedTicket; use rusqlite::Connection; - fn setup() -> (Connection, Project, Agent, Agent) { + fn setup() -> (Connection, Project, Agent, Agent, Agent) { let conn = db::init_in_memory().expect("db init should succeed"); let project = Project::insert(&conn, "Graylog", "/tmp/graylog", None, "main") .expect("project insert should succeed"); @@ -581,13 +591,15 @@ mod tests { "", ) .expect("developer insert should succeed"); + let reviewer = Agent::insert(&conn, "Reviewer", AgentRole::Reviewer, AgentTool::Codex, "") + .expect("reviewer insert should succeed"); - (conn, project, analyst, developer) + (conn, project, analyst, developer, reviewer) } #[test] fn test_upsert_graylog_credentials_for_project() { - let (conn, project, analyst, developer) = setup(); + let (conn, project, analyst, developer, reviewer) = setup(); let creds = GraylogCredentials::upsert_for_project( &conn, @@ -596,6 +608,7 @@ mod tests { "enc-token", &analyst.id, &developer.id, + &reviewer.id, Some("stream-1"), "level:(critical OR error)", 10, @@ -614,6 +627,8 @@ mod tests { .expect("credentials should exist"); assert_eq!(stored.id, creds.id); assert_eq!(stored.analyst_agent_id, analyst.id); + assert_eq!(stored.developer_agent_id, developer.id); + assert_eq!(stored.reviewer_agent_id, reviewer.id); let safe = stored.to_safe(); assert_eq!(safe.project_id, project.id); @@ -627,7 +642,7 @@ mod tests { #[test] fn test_upsert_subject_and_get_by_project_and_key() { - let (conn, project, _, _) = setup(); + let (conn, project, _, _, _) = setup(); let first_seen = "2026-04-17T08:00:00Z"; let second_seen = "2026-04-17T09:00:00Z"; @@ -693,7 +708,7 @@ mod tests { #[test] fn test_insert_detection_and_list_by_project_orders_latest_first() { - let (conn, project, _, _) = setup(); + let (conn, project, _, _, _) = setup(); let subject = GraylogSubject::upsert_subject( &conn, &project.id, @@ -752,7 +767,7 @@ mod tests { #[test] fn test_list_by_project_and_message_returns_all_matching_subjects() { - let (conn, project, _, _) = setup(); + let (conn, project, _, _, _) = setup(); let message = "timeout while calling payment gateway"; GraylogSubject::upsert_subject( diff --git a/src-tauri/src/models/ticket.rs b/src-tauri/src/models/ticket.rs index d2f19ad..579ada1 100644 --- a/src-tauri/src/models/ticket.rs +++ b/src-tauri/src/models/ticket.rs @@ -15,6 +15,7 @@ pub struct ProcessedTicket { pub status: String, pub analyst_report: Option, pub developer_report: Option, + pub review_report: Option, pub worktree_path: Option, pub branch_name: Option, pub detected_at: String, @@ -42,18 +43,19 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result { status: row.get(8)?, analyst_report: row.get(9)?, developer_report: row.get(10)?, - worktree_path: row.get(11)?, - branch_name: row.get(12)?, - detected_at: row.get(13)?, - processed_at: row.get(14)?, + review_report: row.get(11)?, + worktree_path: row.get(12)?, + branch_name: row.get(13)?, + detected_at: row.get(14)?, + processed_at: row.get(15)?, }) } const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, project_id, source, source_ref, \ - artifact_id, artifact_title, artifact_data, status, analyst_report, developer_report, \ + artifact_id, artifact_title, artifact_data, status, analyst_report, developer_report, review_report, \ worktree_path, branch_name, detected_at, processed_at FROM processed_tickets"; const SELECT_SUMMARY_COLS: &str = "SELECT id, tracker_id, project_id, source, source_ref, \ - artifact_id, artifact_title, '' AS artifact_data, status, NULL AS analyst_report, NULL AS developer_report, \ + artifact_id, artifact_title, '' AS artifact_data, status, NULL AS analyst_report, NULL AS developer_report, NULL AS review_report, \ worktree_path, branch_name, detected_at, processed_at FROM processed_tickets"; impl ProcessedTicket { @@ -93,6 +95,7 @@ impl ProcessedTicket { status: "Pending".to_string(), analyst_report: None, developer_report: None, + review_report: None, worktree_path: None, branch_name: None, detected_at: now, @@ -142,6 +145,7 @@ impl ProcessedTicket { status: "Pending".to_string(), analyst_report: None, developer_report: None, + review_report: None, worktree_path: None, branch_name: None, detected_at: now, @@ -272,9 +276,17 @@ impl ProcessedTicket { } pub fn set_developer_report(conn: &Connection, id: &str, report: &str) -> Result<()> { + conn.execute( + "UPDATE processed_tickets SET developer_report = ?1 WHERE id = ?2", + params![report, id], + )?; + Ok(()) + } + + pub fn set_review_report(conn: &Connection, id: &str, report: &str) -> Result<()> { let now = chrono::Utc::now().to_rfc3339(); conn.execute( - "UPDATE processed_tickets SET developer_report = ?1, processed_at = ?2 WHERE id = ?3", + "UPDATE processed_tickets SET review_report = ?1, processed_at = ?2 WHERE id = ?3", params![report, now, id], )?; Ok(()) @@ -305,7 +317,7 @@ impl ProcessedTicket { pub fn list_inflight(conn: &Connection) -> Result> { let sql = format!( - "{} WHERE status IN ('Analyzing', 'Developing') ORDER BY detected_at ASC", + "{} WHERE status IN ('Analyzing', 'Developing', 'Reviewing') ORDER BY detected_at ASC", SELECT_ALL_COLS ); let mut stmt = conn.prepare(&sql)?; @@ -316,7 +328,7 @@ impl ProcessedTicket { pub fn reset_for_retry(conn: &Connection, id: &str) -> Result<()> { conn.execute( "UPDATE processed_tickets \ - SET status = 'Pending', analyst_report = NULL, developer_report = NULL, \ + SET status = 'Pending', analyst_report = NULL, developer_report = NULL, review_report = NULL, \ worktree_path = NULL, branch_name = NULL, processed_at = NULL \ WHERE id = ?1", params![id], @@ -348,6 +360,8 @@ mod tests { let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let reviewer = + Agent::insert(&conn, "R", AgentRole::Reviewer, AgentTool::Codex, "").unwrap(); let tracker = WatchedTracker::insert( &conn, NewWatchedTracker { @@ -357,6 +371,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer.id.clone(), + reviewer_agent_id: reviewer.id.clone(), filters: vec![], }, ) @@ -623,6 +638,19 @@ mod tests { updated.developer_report.as_deref(), Some("Fixed in main.rs") ); + assert!(updated.processed_at.is_none()); + } + + #[test] + fn test_set_review_report_sets_processed_at() { + let (conn, project_id, tracker_id) = setup(); + let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 2, "T2", "{}") + .unwrap() + .unwrap(); + + ProcessedTicket::set_review_report(&conn, &ticket.id, "Review ok").unwrap(); + let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap(); + assert_eq!(updated.review_report.as_deref(), Some("Review ok")); let processed_at = updated .processed_at .as_deref() @@ -678,6 +706,7 @@ mod tests { assert_eq!(updated.status, "Pending"); assert!(updated.analyst_report.is_none()); assert!(updated.developer_report.is_none()); + assert!(updated.review_report.is_none()); assert!(updated.worktree_path.is_none()); assert!(updated.branch_name.is_none()); assert!(updated.processed_at.is_none()); @@ -844,6 +873,8 @@ mod tests { assert!(!tickets.is_empty()); assert!(tickets.iter().all(|ticket| ticket.artifact_data.is_empty())); assert!(tickets.iter().all(|ticket| ticket.analyst_report.is_none())); - assert!(tickets.iter().all(|ticket| ticket.developer_report.is_none())); + assert!(tickets + .iter() + .all(|ticket| ticket.developer_report.is_none())); } } diff --git a/src-tauri/src/models/tracker.rs b/src-tauri/src/models/tracker.rs index 23a9858..55a022a 100644 --- a/src-tauri/src/models/tracker.rs +++ b/src-tauri/src/models/tracker.rs @@ -23,6 +23,7 @@ pub struct WatchedTracker { pub polling_interval: i32, pub analyst_agent_id: Option, pub developer_agent_id: Option, + pub reviewer_agent_id: Option, pub filters: Vec, pub enabled: bool, pub status: String, @@ -37,6 +38,7 @@ pub struct TrackerUpdate { pub polling_interval: i32, pub analyst_agent_id: String, pub developer_agent_id: String, + pub reviewer_agent_id: String, pub filters: Vec, pub enabled: bool, } @@ -49,6 +51,7 @@ pub struct NewWatchedTracker { pub polling_interval: i32, pub analyst_agent_id: String, pub developer_agent_id: String, + pub reviewer_agent_id: String, pub filters: Vec, } @@ -64,8 +67,9 @@ fn normalize_agent_id(agent_id: &str) -> Option { fn compute_status( analyst_agent_id: &Option, developer_agent_id: &Option, + reviewer_agent_id: &Option, ) -> String { - if analyst_agent_id.is_some() && developer_agent_id.is_some() { + if analyst_agent_id.is_some() && developer_agent_id.is_some() && reviewer_agent_id.is_some() { "valid".to_string() } else { "invalid".to_string() @@ -73,11 +77,11 @@ fn compute_status( } fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - let filters_json: String = row.get(7)?; - let enabled_int: i32 = row.get(8)?; + let filters_json: String = row.get(8)?; + let enabled_int: i32 = row.get(9)?; let filters: Vec = serde_json::from_str(&filters_json).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e)) + rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(e)) })?; Ok(WatchedTracker { @@ -88,11 +92,12 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result { polling_interval: row.get(4)?, analyst_agent_id: row.get(5)?, developer_agent_id: row.get(6)?, + reviewer_agent_id: row.get(7)?, filters, enabled: enabled_int != 0, - status: row.get(9)?, - last_polled_at: row.get(10)?, - created_at: row.get(11)?, + status: row.get(10)?, + last_polled_at: row.get(11)?, + created_at: row.get(12)?, }) } @@ -105,6 +110,7 @@ impl WatchedTracker { polling_interval, analyst_agent_id, developer_agent_id, + reviewer_agent_id, filters, } = new_tracker; @@ -116,11 +122,12 @@ impl WatchedTracker { let analyst_agent_id = normalize_agent_id(&analyst_agent_id); let developer_agent_id = normalize_agent_id(&developer_agent_id); - let status = compute_status(&analyst_agent_id, &developer_agent_id); + let reviewer_agent_id = normalize_agent_id(&reviewer_agent_id); + let status = compute_status(&analyst_agent_id, &developer_agent_id, &reviewer_agent_id); conn.execute( - "INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, analyst_agent_id, developer_agent_id, status, created_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, analyst_agent_id, developer_agent_id, reviewer_agent_id, status, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ &id, &project_id, @@ -131,6 +138,7 @@ impl WatchedTracker { filters_json, analyst_agent_id.as_deref(), developer_agent_id.as_deref(), + reviewer_agent_id.as_deref(), &status, &now, ], @@ -144,6 +152,7 @@ impl WatchedTracker { polling_interval, analyst_agent_id, developer_agent_id, + reviewer_agent_id, filters, enabled: true, status, @@ -154,7 +163,7 @@ impl WatchedTracker { pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { let mut stmt = conn.prepare( - "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, reviewer_agent_id, filters_json, enabled, status, 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)?; @@ -163,12 +172,13 @@ impl WatchedTracker { pub fn list_all_enabled(conn: &Connection) -> Result> { let mut stmt = conn.prepare( - "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, reviewer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ FROM watched_trackers \ WHERE enabled = 1 \ AND status = 'valid' \ AND analyst_agent_id IS NOT NULL \ AND developer_agent_id IS NOT NULL \ + AND reviewer_agent_id IS NOT NULL \ AND (\n\ EXISTS (\n\ SELECT 1\n\ @@ -192,7 +202,7 @@ impl WatchedTracker { pub fn get_by_id(conn: &Connection, id: &str) -> Result { conn.query_row( - "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, reviewer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ FROM watched_trackers WHERE id = ?1", params![id], from_row, @@ -206,10 +216,11 @@ impl WatchedTracker { let analyst_agent_id = normalize_agent_id(&update.analyst_agent_id); let developer_agent_id = normalize_agent_id(&update.developer_agent_id); - let status = compute_status(&analyst_agent_id, &developer_agent_id); + let reviewer_agent_id = normalize_agent_id(&update.reviewer_agent_id); + let status = compute_status(&analyst_agent_id, &developer_agent_id, &reviewer_agent_id); let affected = conn.execute( - "UPDATE watched_trackers SET tracker_id = ?1, tracker_label = ?2, polling_interval = ?3, filters_json = ?4, analyst_agent_id = ?5, developer_agent_id = ?6, status = ?7, enabled = ?8 WHERE id = ?9", + "UPDATE watched_trackers SET tracker_id = ?1, tracker_label = ?2, polling_interval = ?3, filters_json = ?4, analyst_agent_id = ?5, developer_agent_id = ?6, reviewer_agent_id = ?7, status = ?8, enabled = ?9 WHERE id = ?10", params![ update.tracker_id, update.tracker_label, @@ -217,6 +228,7 @@ impl WatchedTracker { filters_json, analyst_agent_id, developer_agent_id, + reviewer_agent_id, status, enabled_int, id @@ -267,7 +279,7 @@ mod tests { Project::list(conn).unwrap().into_iter().next().unwrap().id } - fn create_agents(conn: &Connection) -> (String, String) { + fn create_agents(conn: &Connection) -> (String, String, String) { let analyst = Agent::insert(conn, "Analyst", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); @@ -280,7 +292,10 @@ mod tests { ) .unwrap(); - (analyst.id, developer.id) + let reviewer = + Agent::insert(conn, "Reviewer", AgentRole::Reviewer, AgentTool::Codex, "").unwrap(); + + (analyst.id, developer.id, reviewer.id) } fn sample_filters() -> Vec { @@ -297,7 +312,7 @@ mod tests { fn test_insert_tracker() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); let tracker = WatchedTracker::insert( &conn, @@ -308,6 +323,7 @@ mod tests { polling_interval: 15, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: sample_filters(), }, ) @@ -328,6 +344,10 @@ mod tests { tracker.developer_agent_id.as_deref(), Some(developer_id.as_str()) ); + assert_eq!( + tracker.reviewer_agent_id.as_deref(), + Some(reviewer_id.as_str()) + ); assert!(tracker.last_polled_at.is_none()); assert!(!tracker.created_at.is_empty()); assert_eq!(tracker.filters.len(), 1); @@ -337,7 +357,7 @@ mod tests { fn test_list_by_project() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); WatchedTracker::insert( &conn, @@ -348,6 +368,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: vec![], }, ) @@ -361,6 +382,7 @@ mod tests { polling_interval: 20, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: vec![], }, ) @@ -374,7 +396,7 @@ mod tests { fn test_list_all_enabled_ignores_invalid() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); let valid = WatchedTracker::insert( &conn, @@ -385,6 +407,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: vec![], }, ) @@ -398,6 +421,7 @@ mod tests { polling_interval: 10, analyst_agent_id: "".to_string(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: vec![], }, ) @@ -413,7 +437,7 @@ mod tests { fn test_get_by_id() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); let created = WatchedTracker::insert( &conn, @@ -424,6 +448,7 @@ mod tests { polling_interval: 30, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: sample_filters(), }, ) @@ -442,7 +467,7 @@ mod tests { fn test_update_tracker() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); let created = WatchedTracker::insert( &conn, @@ -453,6 +478,7 @@ mod tests { polling_interval: 5, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: sample_filters(), }, ) @@ -475,6 +501,7 @@ mod tests { polling_interval: 60, analyst_agent_id: analyst_id, developer_agent_id: developer_id, + reviewer_agent_id: reviewer_id, filters: new_filters, enabled: false, }, @@ -494,7 +521,7 @@ mod tests { fn test_update_last_polled() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); let created = WatchedTracker::insert( &conn, @@ -505,6 +532,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: vec![], }, ) @@ -527,7 +555,7 @@ mod tests { fn test_delete_tracker() { let conn = setup(); let pid = project_id(&conn); - let (analyst_id, developer_id) = create_agents(&conn); + let (analyst_id, developer_id, reviewer_id) = create_agents(&conn); let created = WatchedTracker::insert( &conn, @@ -538,6 +566,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst_id.clone(), developer_agent_id: developer_id.clone(), + reviewer_agent_id: reviewer_id.clone(), filters: vec![], }, ) diff --git a/src-tauri/src/models/worktree.rs b/src-tauri/src/models/worktree.rs index a52dda4..e7fec92 100644 --- a/src-tauri/src/models/worktree.rs +++ b/src-tauri/src/models/worktree.rs @@ -116,6 +116,8 @@ mod tests { let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let reviewer = + Agent::insert(&conn, "R", AgentRole::Reviewer, AgentTool::Codex, "").unwrap(); let tracker = WatchedTracker::insert( &conn, NewWatchedTracker { @@ -125,6 +127,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer.id.clone(), + reviewer_agent_id: reviewer.id.clone(), filters: vec![], }, ) @@ -171,6 +174,8 @@ mod tests { let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let reviewer = + Agent::insert(&conn, "R", AgentRole::Reviewer, AgentTool::Codex, "").unwrap(); let tracker = WatchedTracker::insert( &conn, NewWatchedTracker { @@ -180,6 +185,7 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer.id.clone(), + reviewer_agent_id: reviewer.id.clone(), filters: vec![], }, ) diff --git a/src-tauri/src/services/activity_state.rs b/src-tauri/src/services/activity_state.rs index 69f8d97..43684ee 100644 --- a/src-tauri/src/services/activity_state.rs +++ b/src-tauri/src/services/activity_state.rs @@ -19,11 +19,13 @@ impl ActivityState { .active_polls .lock() .unwrap_or_else(|poison| poison.into_inner()); - let entry = polls.entry(key.to_string()).or_insert_with(|| ActivePollEntry { - project_id: project_id.to_string(), - label: label.to_string(), - count: 0, - }); + let entry = polls + .entry(key.to_string()) + .or_insert_with(|| ActivePollEntry { + project_id: project_id.to_string(), + label: label.to_string(), + count: 0, + }); entry.project_id = project_id.to_string(); entry.label = label.to_string(); entry.count += 1; diff --git a/src-tauri/src/services/graylog_client.rs b/src-tauri/src/services/graylog_client.rs index f52e499..4bb9087 100644 --- a/src-tauri/src/services/graylog_client.rs +++ b/src-tauri/src/services/graylog_client.rs @@ -353,9 +353,7 @@ mod tests { assert!(url.contains("/api/search/universal/relative?")); assert!(url.contains("query=level%3A%3C3")); assert!(url.contains("range=1800")); - assert!(url.contains( - "filter=streams%3A000000000000000000000001" - )); + assert!(url.contains("filter=streams%3A000000000000000000000001")); assert!(!url.contains("&streams=")); } diff --git a/src-tauri/src/services/graylog_poller.rs b/src-tauri/src/services/graylog_poller.rs index 048cd5d..2ce0d1f 100644 --- a/src-tauri/src/services/graylog_poller.rs +++ b/src-tauri/src/services/graylog_poller.rs @@ -12,7 +12,7 @@ use tauri::{AppHandle, Emitter}; use tokio::time::{interval, Duration}; fn is_ticket_active(status: &str) -> bool { - matches!(status, "Pending" | "Analyzing" | "Developing") + matches!(status, "Pending" | "Analyzing" | "Developing" | "Reviewing") } fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -> bool { diff --git a/src-tauri/src/services/notifier.rs b/src-tauri/src/services/notifier.rs index 070afd8..0753b4c 100644 --- a/src-tauri/src/services/notifier.rs +++ b/src-tauri/src/services/notifier.rs @@ -156,6 +156,8 @@ mod tests { let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let reviewer = + Agent::insert(&conn, "R", AgentRole::Reviewer, AgentTool::Codex, "").unwrap(); let tracker = WatchedTracker::insert( &conn, @@ -166,22 +168,22 @@ mod tests { polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer.id.clone(), + reviewer_agent_id: reviewer.id.clone(), filters: vec![], }, ) .expect("tracker insert should succeed"); - let ticket = - ProcessedTicket::insert_if_new( - &conn, - project_id, - &tracker.id, - 1, - "Ticket 1", - "{\"id\":1}", - ) - .expect("ticket insert should succeed") - .expect("ticket should be inserted"); + let ticket = ProcessedTicket::insert_if_new( + &conn, + project_id, + &tracker.id, + 1, + "Ticket 1", + "{\"id\":1}", + ) + .expect("ticket insert should succeed") + .expect("ticket should be inserted"); ticket.id } diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index e84bd9f..0590048 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -139,6 +139,57 @@ pub fn build_developer_prompt( ) } +pub fn build_review_prompt( + ticket: &ProcessedTicket, + project: &Project, + analyst_report: &str, + developer_report: &str, + worktree_path: &str, + branch_name: &str, +) -> String { + let source_ref = ticket.source_ref.as_deref().unwrap_or("-"); + format!( + r#"Tu es un reviewer technique. Tu dois valider la correction proposee par le developpeur. + +## Ticket +- ID: {artifact_id} +- Titre: {title} +- Source: {source} +- Source ref: {source_ref} + +## Contexte +- Projet: {project_name} +- Repo (worktree): {worktree_path} +- Branche de base: {base_branch} +- Branche de travail: {branch_name} + +## Rapport analyste +{analyst_report} + +## Rapport developpeur +{developer_report} + +## Ta mission +1. Verifie la coherence entre l'analyse, le correctif et le ticket. +2. Verifie la qualite des changements (risques, dette, regressions possibles, tests manquants). +3. Produis un rapport markdown structuré avec: + - Synthese + - Points conformes + - Risques / points a corriger + - Verdict final"#, + artifact_id = ticket.artifact_id, + title = ticket.artifact_title, + source = ticket.source, + source_ref = source_ref, + project_name = project.name, + worktree_path = worktree_path, + base_branch = project.base_branch, + branch_name = branch_name, + analyst_report = analyst_report, + developer_report = developer_report, + ) +} + fn append_custom_prompt(base_prompt: String, custom_prompt: &str) -> String { let extra = custom_prompt.trim(); if extra.is_empty() { @@ -551,8 +602,8 @@ async fn process_ticket( } }; - let (analyst_agent, developer_agent) = { - let (analyst_id, developer_id) = if ticket.source == "graylog" { + let (analyst_agent, developer_agent, reviewer_agent) = { + let (analyst_id, developer_id, reviewer_id) = if ticket.source == "graylog" { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; let config = match GraylogCredentials::get_by_project(&conn, &project.id) .map_err(|e| format!("graylog credentials lookup failed: {}", e))? @@ -575,6 +626,7 @@ async fn process_ticket( ( config.analyst_agent_id.to_string(), config.developer_agent_id.to_string(), + config.reviewer_agent_id.to_string(), ) } else if ticket.source == "tuleap" { let tracker = match &tracker { @@ -599,7 +651,7 @@ async fn process_ticket( &project.id, &ticket.id, ticket.artifact_id, - "Tracker is invalid. Configure analyst and developer agents.", + "Tracker is invalid. Configure analyst, developer and reviewer agents.", ); return Ok(true); } @@ -634,7 +686,22 @@ async fn process_ticket( } }; - (analyst_id, developer_id) + let reviewer_id = match tracker.reviewer_agent_id.as_deref() { + Some(id) => id.to_string(), + None => { + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Tracker has no reviewer agent configured.", + ); + return Ok(true); + } + }; + + (analyst_id, developer_id, reviewer_id) } else { record_ticket_error( db, @@ -680,6 +747,22 @@ async fn process_ticket( } }; + let reviewer_agent = match Agent::get_by_id(&conn, &reviewer_id) { + Ok(agent) => agent, + Err(_) => { + drop(conn); + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Configured reviewer agent was not found.", + ); + return Ok(true); + } + }; + if analyst_agent.role != AgentRole::Analyst { drop(conn); record_ticket_error( @@ -706,7 +789,20 @@ async fn process_ticket( return Ok(true); } - (analyst_agent, developer_agent) + if reviewer_agent.role != AgentRole::Reviewer { + drop(conn); + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Configured reviewer agent has an invalid role.", + ); + return Ok(true); + } + + (analyst_agent, developer_agent, reviewer_agent) }; { @@ -905,6 +1001,72 @@ async fn process_ticket( let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report) .map_err(|e| format!("set_developer_report: {}", e))?; + ProcessedTicket::update_status(&conn, &ticket.id, "Reviewing") + .map_err(|e| format!("update_status: {}", e))?; + } + + let _ = app_handle.emit( + "ticket-processing-started", + serde_json::json!({ + "project_id": &project.id, + "ticket_id": &ticket.id, + "artifact_id": ticket.artifact_id, + "step": "review", + }), + ); + + let review_prompt = append_custom_prompt( + build_review_prompt( + &ticket, + &project, + &analyst_report, + &developer_report, + &wt_path, + &branch_name, + ), + &reviewer_agent.custom_prompt, + ); + let review_args = build_agent_cli_args(&reviewer_agent, &wt_path); + let review_result = run_cli_command( + reviewer_agent.tool.to_command(), + &review_args, + &review_prompt, + &wt_path, + 600, + TicketCliContext { + app_handle, + ticket_id: &ticket.id, + process_registry, + }, + ) + .await; + + let review_report = match review_result { + Ok(report) => report, + Err(e) => { + if is_ticket_cancelled(db, &ticket.id)? { + return Ok(true); + } + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + &e, + ); + return Ok(true); + } + }; + + if is_ticket_cancelled(db, &ticket.id)? { + return Ok(true); + } + + { + let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; + ProcessedTicket::set_review_report(&conn, &ticket.id, &review_report) + .map_err(|e| format!("set_review_report: {}", e))?; ProcessedTicket::update_status(&conn, &ticket.id, "Done") .map_err(|e| format!("update_status: {}", e))?; } @@ -974,6 +1136,7 @@ mod tests { status: "Pending".into(), analyst_report: None, developer_report: None, + review_report: None, worktree_path: None, branch_name: None, detected_at: "2026-01-01T00:00:00Z".into(), @@ -1012,6 +1175,7 @@ mod tests { status: "Developing".into(), analyst_report: None, developer_report: None, + review_report: None, worktree_path: None, branch_name: None, detected_at: "2026-01-01T00:00:00Z".into(), diff --git a/src-tauri/src/services/poller.rs b/src-tauri/src/services/poller.rs index 3568916..127ce09 100644 --- a/src-tauri/src/services/poller.rs +++ b/src-tauri/src/services/poller.rs @@ -20,8 +20,14 @@ pub fn start( let mut tick = interval(Duration::from_secs(60)); loop { tick.tick().await; - poll_all_trackers(&db, &encryption_key, &http_client, &app_handle, &activity_state) - .await; + poll_all_trackers( + &db, + &encryption_key, + &http_client, + &app_handle, + &activity_state, + ) + .await; } }); } diff --git a/src/components/agents/AgentForm.tsx b/src/components/agents/AgentForm.tsx index 9f437f4..c2efb75 100644 --- a/src/components/agents/AgentForm.tsx +++ b/src/components/agents/AgentForm.tsx @@ -117,6 +117,7 @@ export default function AgentForm() { > + diff --git a/src/components/agents/AgentList.tsx b/src/components/agents/AgentList.tsx index 5084bd0..0b05784 100644 --- a/src/components/agents/AgentList.tsx +++ b/src/components/agents/AgentList.tsx @@ -51,7 +51,16 @@ export default function AgentList() { } function roleLabel(role: Agent["role"]): string { - return role === "analyst" ? "Analyst" : "Developer"; + switch (role) { + case "analyst": + return "Analyst"; + case "developer": + return "Developer"; + case "reviewer": + return "Reviewer"; + default: + return role; + } } function toolLabel(tool: Agent["tool"]): string { diff --git a/src/components/projects/ProjectDashboard.tsx b/src/components/projects/ProjectDashboard.tsx index 8a5296d..5bb0c89 100644 --- a/src/components/projects/ProjectDashboard.tsx +++ b/src/components/projects/ProjectDashboard.tsx @@ -44,7 +44,7 @@ interface TicketProcessingPayload { project_id: string; ticket_id: string; artifact_id: number; - step?: "analyst" | "developer"; + step?: "analyst" | "developer" | "review"; error?: string; } diff --git a/src/components/projects/ProjectGraylog.tsx b/src/components/projects/ProjectGraylog.tsx index 3e6acfa..8b93711 100644 --- a/src/components/projects/ProjectGraylog.tsx +++ b/src/components/projects/ProjectGraylog.tsx @@ -46,6 +46,7 @@ export default function ProjectGraylog() { const [apiToken, setApiToken] = useState(""); const [analystAgentId, setAnalystAgentId] = useState(""); const [developerAgentId, setDeveloperAgentId] = useState(""); + const [reviewerAgentId, setReviewerAgentId] = useState(""); const [streamId, setStreamId] = useState(""); const [queryFilter, setQueryFilter] = useState("level:(critical OR error OR warning)"); const [pollingIntervalMinutes, setPollingIntervalMinutes] = useState(10); @@ -61,6 +62,7 @@ export default function ProjectGraylog() { const analysts = agents.filter((agent) => agent.role === "analyst"); const developers = agents.filter((agent) => agent.role === "developer"); + const reviewers = agents.filter((agent) => agent.role === "reviewer"); const hasProjectScopedTuleapCredentials = Boolean(projectId) && tuleapCredentials?.project_id === projectId; const usingGlobalTuleapFallback = @@ -100,6 +102,7 @@ export default function ProjectGraylog() { setBaseUrl(creds.base_url); setAnalystAgentId(creds.analyst_agent_id); setDeveloperAgentId(creds.developer_agent_id); + setReviewerAgentId(creds.reviewer_agent_id); setStreamId(creds.stream_id ?? ""); setQueryFilter(creds.query_filter); setPollingIntervalMinutes(creds.polling_interval_minutes); @@ -169,6 +172,7 @@ export default function ProjectGraylog() { apiToken, analystAgentId, developerAgentId, + reviewerAgentId, streamId.trim() || null, queryFilter, pollingIntervalMinutes, @@ -322,7 +326,7 @@ export default function ProjectGraylog() { required={!credentials} /> -
+
+
diff --git a/src/components/projects/ProjectLiveAgent.tsx b/src/components/projects/ProjectLiveAgent.tsx index 17a86ea..a74c21b 100644 --- a/src/components/projects/ProjectLiveAgent.tsx +++ b/src/components/projects/ProjectLiveAgent.tsx @@ -87,7 +87,11 @@ export default function ProjectLiveAgent() { const [error, setError] = useState(null); const usableAgents = useMemo( - () => agents.filter((agent) => agent.role === "analyst" || agent.role === "developer"), + () => + agents.filter( + (agent) => + agent.role === "analyst" || agent.role === "developer" || agent.role === "reviewer" + ), [agents] ); const selectedSession = useMemo( diff --git a/src/components/projects/ProjectTasks.tsx b/src/components/projects/ProjectTasks.tsx index 335bcaa..7f6282c 100644 --- a/src/components/projects/ProjectTasks.tsx +++ b/src/components/projects/ProjectTasks.tsx @@ -43,7 +43,11 @@ export default function ProjectTasks() { const [error, setError] = useState(null); const usableAgents = useMemo( - () => agents.filter((agent) => agent.role === "analyst" || agent.role === "developer"), + () => + agents.filter( + (agent) => + agent.role === "analyst" || agent.role === "developer" || agent.role === "reviewer" + ), [agents] ); const selectedAgent = useMemo( diff --git a/src/components/tickets/TicketDetail.tsx b/src/components/tickets/TicketDetail.tsx index 1ae6800..594dd19 100644 --- a/src/components/tickets/TicketDetail.tsx +++ b/src/components/tickets/TicketDetail.tsx @@ -73,7 +73,7 @@ export default function TicketDetail() { const [branchesError, setBranchesError] = useState(""); const [diffLoading, setDiffLoading] = useState(false); const [diffError, setDiffError] = useState(""); - const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info"); + const [tab, setTab] = useState<"info" | "analyst" | "developer" | "review" | "diff">("info"); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false); @@ -318,6 +318,11 @@ export default function TicketDetail() { label: "Developer Report", disabled: !ticket.developer_report, }, + { + key: "review" as const, + label: "Review Report", + disabled: !ticket.review_report, + }, { key: "diff" as const, label: "Diff", @@ -354,7 +359,8 @@ export default function TicketDetail() { )} {(ticket.status === "Pending" || ticket.status === "Analyzing" || - ticket.status === "Developing") && ( + ticket.status === "Developing" || + ticket.status === "Reviewing") && (
- {["all", "Pending", "Analyzing", "Developing", "Done", "Error"].map((s) => ( + {["all", "Pending", "Analyzing", "Developing", "Reviewing", "Done", "Error"].map((s) => (
+
+ + +
{error && ( diff --git a/src/components/trackers/TrackerList.tsx b/src/components/trackers/TrackerList.tsx index e4a44e0..e2956de 100644 --- a/src/components/trackers/TrackerList.tsx +++ b/src/components/trackers/TrackerList.tsx @@ -39,6 +39,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) { tracker.polling_interval, tracker.analyst_agent_id ?? "", tracker.developer_agent_id ?? "", + tracker.reviewer_agent_id ?? "", tracker.filters, !tracker.enabled ); diff --git a/src/components/ui/TicketStatusBadge.tsx b/src/components/ui/TicketStatusBadge.tsx index c6158a1..223deff 100644 --- a/src/components/ui/TicketStatusBadge.tsx +++ b/src/components/ui/TicketStatusBadge.tsx @@ -4,6 +4,7 @@ const statusClasses: Record = { Pending: "bg-yellow-100 text-yellow-700", Analyzing: "bg-blue-100 text-blue-700", Developing: "bg-purple-100 text-purple-700", + Reviewing: "bg-indigo-100 text-indigo-700", Done: "bg-green-100 text-green-700", Error: "bg-red-100 text-red-700", Cancelled: "bg-gray-100 text-gray-500", diff --git a/src/lib/api.ts b/src/lib/api.ts index 3477cea..5c8ffc3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -118,6 +118,7 @@ export async function setGraylogCredentials( apiToken: string, analystAgentId: string, developerAgentId: string, + reviewerAgentId: string, streamId: string | null, queryFilter: string, pollingIntervalMinutes: number, @@ -130,6 +131,7 @@ export async function setGraylogCredentials( apiToken, analystAgentId, developerAgentId, + reviewerAgentId, streamId, queryFilter, pollingIntervalMinutes, @@ -173,6 +175,7 @@ export async function addTracker( pollingInterval: number, analystAgentId: string, developerAgentId: string, + reviewerAgentId: string, filters: FilterGroup[] ): Promise { return invoke("add_tracker", { @@ -183,6 +186,7 @@ export async function addTracker( pollingInterval, analystAgentId, developerAgentId, + reviewerAgentId, filters, }, }); @@ -197,6 +201,7 @@ export async function updateTracker( pollingInterval: number, analystAgentId: string, developerAgentId: string, + reviewerAgentId: string, filters: FilterGroup[], enabled: boolean ): Promise { @@ -208,6 +213,7 @@ export async function updateTracker( polling_interval: pollingInterval, analyst_agent_id: analystAgentId, developer_agent_id: developerAgentId, + reviewer_agent_id: reviewerAgentId, filters, enabled, }, diff --git a/src/lib/types.ts b/src/lib/types.ts index dfb2c5c..f4e93c8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,7 +14,7 @@ export interface TuleapCredentialsSafe { username: string; } -export type AgentRole = "analyst" | "developer"; +export type AgentRole = "analyst" | "developer" | "reviewer"; export type AgentTool = "codex" | "claude_code"; export type AgentRuntimeStatus = "available" | "exhausted"; @@ -62,6 +62,7 @@ export interface WatchedTracker { polling_interval: number; analyst_agent_id: string | null; developer_agent_id: string | null; + reviewer_agent_id: string | null; filters: FilterGroup[]; enabled: boolean; status: string; @@ -81,6 +82,7 @@ export interface ProcessedTicket { status: string; analyst_report: string | null; developer_report: string | null; + review_report: string | null; worktree_path: string | null; branch_name: string | null; detected_at: string; @@ -93,6 +95,7 @@ export interface GraylogCredentialsSafe { base_url: string; analyst_agent_id: string; developer_agent_id: string; + reviewer_agent_id: string; stream_id: string | null; query_filter: string; polling_interval_minutes: number;