feat: add reviewer stage and default codex reviewer agent

This commit is contained in:
thibaud-lclr 2026-04-22 09:06:00 +02:00
parent f9b565cfda
commit c35778fe18
31 changed files with 563 additions and 122 deletions

View file

@ -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;

View file

@ -11,6 +11,7 @@ fn agent_role_label(role: &AgentRole) -> &'static str {
match role {
AgentRole::Analyst => "analyste",
AgentRole::Developer => "developpeur",
AgentRole::Reviewer => "reviewer",
}
}
@ -141,27 +142,22 @@ 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",
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],
)?;
}
AgentRole::Developer => {
db.execute(
"UPDATE watched_trackers
SET analyst_agent_id = NULL,
status = 'invalid'
WHERE analyst_agent_id = ?1",
params![id],
)?;
}
}
}
Ok(())
}

View file

@ -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<String>,
query_filter: String,
polling_interval_minutes: i32,
lookback_minutes: i32,
score_threshold: i32,
) -> Result<GraylogCredentialsSafe, AppError> {
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,

View file

@ -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)?;

View file

@ -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<String, RuntimeActiveAgent> = 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 {

View file

@ -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<FilterGroup>,
}
@ -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(())

View file

@ -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<Connection> {
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]

View file

@ -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())
);
}
}

View file

@ -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<String>,
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<String>,
pub query_filter: String,
pub polling_interval_minutes: i32,
@ -80,13 +82,14 @@ fn credentials_from_row(row: &rusqlite::Row) -> rusqlite::Result<GraylogCredenti
api_token_encrypted: row.get(3)?,
analyst_agent_id: row.get(4)?,
developer_agent_id: row.get(5)?,
stream_id: row.get(6)?,
query_filter: row.get(7)?,
polling_interval_minutes: row.get(8)?,
lookback_minutes: row.get(9)?,
score_threshold: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
reviewer_agent_id: row.get(6)?,
stream_id: row.get(7)?,
query_filter: row.get(8)?,
polling_interval_minutes: row.get(9)?,
lookback_minutes: row.get(10)?,
score_threshold: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
})
}
@ -136,6 +139,7 @@ impl GraylogCredentials {
api_token_encrypted: &str,
analyst_agent_id: &str,
developer_agent_id: &str,
reviewer_agent_id: &str,
stream_id: Option<&str>,
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(

View file

@ -15,6 +15,7 @@ pub struct ProcessedTicket {
pub status: String,
pub analyst_report: Option<String>,
pub developer_report: Option<String>,
pub review_report: Option<String>,
pub worktree_path: Option<String>,
pub branch_name: Option<String>,
pub detected_at: String,
@ -42,18 +43,19 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
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<Vec<ProcessedTicket>> {
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()));
}
}

View file

@ -23,6 +23,7 @@ pub struct WatchedTracker {
pub polling_interval: i32,
pub analyst_agent_id: Option<String>,
pub developer_agent_id: Option<String>,
pub reviewer_agent_id: Option<String>,
pub filters: Vec<FilterGroup>,
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<FilterGroup>,
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<FilterGroup>,
}
@ -64,8 +67,9 @@ fn normalize_agent_id(agent_id: &str) -> Option<String> {
fn compute_status(
analyst_agent_id: &Option<String>,
developer_agent_id: &Option<String>,
reviewer_agent_id: &Option<String>,
) -> 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<WatchedTracker> {
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<FilterGroup> = 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<WatchedTracker> {
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<Vec<WatchedTracker>> {
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<Vec<WatchedTracker>> {
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<WatchedTracker> {
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<FilterGroup> {
@ -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![],
},
)

View file

@ -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![],
},
)

View file

@ -19,7 +19,9 @@ impl ActivityState {
.active_polls
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let entry = polls.entry(key.to_string()).or_insert_with(|| ActivePollEntry {
let entry = polls
.entry(key.to_string())
.or_insert_with(|| ActivePollEntry {
project_id: project_id.to_string(),
label: label.to_string(),
count: 0,

View file

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

View file

@ -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 {

View file

@ -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,13 +168,13 @@ 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(
let ticket = ProcessedTicket::insert_if_new(
&conn,
project_id,
&tracker.id,

View file

@ -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(),

View file

@ -20,7 +20,13 @@ 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)
poll_all_trackers(
&db,
&encryption_key,
&http_client,
&app_handle,
&activity_state,
)
.await;
}
});

View file

@ -117,6 +117,7 @@ export default function AgentForm() {
>
<option value="analyst">Analyst</option>
<option value="developer">Developer</option>
<option value="reviewer">Reviewer</option>
</select>
</div>

View file

@ -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 {

View file

@ -44,7 +44,7 @@ interface TicketProcessingPayload {
project_id: string;
ticket_id: string;
artifact_id: number;
step?: "analyst" | "developer";
step?: "analyst" | "developer" | "review";
error?: string;
}

View file

@ -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}
/>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<select
className={inputClass}
value={analystAgentId}
@ -350,6 +354,19 @@ export default function ProjectGraylog() {
</option>
))}
</select>
<select
className={inputClass}
value={reviewerAgentId}
onChange={(event) => setReviewerAgentId(event.target.value)}
required
>
<option value="">Reviewer agent</option>
{reviewers.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
<div className="grid gap-3 md:grid-cols-4">

View file

@ -87,7 +87,11 @@ export default function ProjectLiveAgent() {
const [error, setError] = useState<string | null>(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(

View file

@ -43,7 +43,11 @@ export default function ProjectTasks() {
const [error, setError] = useState<string | null>(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(

View file

@ -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") && (
<button
onClick={handleCancel}
disabled={loading}
@ -542,6 +548,12 @@ export default function TicketDetail() {
</div>
)}
{tab === "review" && ticket.review_report && (
<div className="markdown-report rounded-lg border border-gray-200 bg-white p-6">
<Markdown remarkPlugins={[remarkGfm]}>{ticket.review_report}</Markdown>
</div>
)}
{tab === "diff" && (
<>
{diffLoading ? (

View file

@ -83,7 +83,7 @@ export default function TicketList() {
</div>
<div className="mb-4 flex gap-2">
{["all", "Pending", "Analyzing", "Developing", "Done", "Error"].map((s) => (
{["all", "Pending", "Analyzing", "Developing", "Reviewing", "Done", "Error"].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}

View file

@ -35,6 +35,7 @@ export default function TrackerConfig() {
const [agents, setAgents] = useState<Agent[]>([]);
const [analystAgentId, setAnalystAgentId] = useState("");
const [developerAgentId, setDeveloperAgentId] = useState("");
const [reviewerAgentId, setReviewerAgentId] = useState("");
const [enabled, setEnabled] = useState(true);
const [trackerStatus, setTrackerStatus] = useState<"valid" | "invalid">("valid");
const [error, setError] = useState<string | null>(null);
@ -56,6 +57,7 @@ export default function TrackerConfig() {
const analystAgents = agents.filter((agent) => agent.role === "analyst");
const developerAgents = agents.filter((agent) => agent.role === "developer");
const reviewerAgents = agents.filter((agent) => agent.role === "reviewer");
useEffect(() => {
async function loadAgents() {
@ -78,7 +80,18 @@ export default function TrackerConfig() {
if (!developerAgentId && developerAgents.length > 0) {
setDeveloperAgentId(developerAgents[0].id);
}
}, [isEditing, analystAgentId, analystAgents, developerAgentId, developerAgents]);
if (!reviewerAgentId && reviewerAgents.length > 0) {
setReviewerAgentId(reviewerAgents[0].id);
}
}, [
isEditing,
analystAgentId,
analystAgents,
developerAgentId,
developerAgents,
reviewerAgentId,
reviewerAgents,
]);
useEffect(() => {
async function loadTrackerForEdit() {
@ -98,6 +111,7 @@ export default function TrackerConfig() {
setFilters(tracker.filters);
setAnalystAgentId(tracker.analyst_agent_id ?? "");
setDeveloperAgentId(tracker.developer_agent_id ?? "");
setReviewerAgentId(tracker.reviewer_agent_id ?? "");
setEnabled(tracker.enabled);
setTrackerStatus(tracker.status === "invalid" ? "invalid" : "valid");
@ -132,8 +146,8 @@ export default function TrackerConfig() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!projectId || trackerId === "") return;
if (!analystAgentId || !developerAgentId) {
setError("Please select one analyst agent and one developer agent.");
if (!analystAgentId || !developerAgentId || !reviewerAgentId) {
setError("Please select one analyst agent, one developer agent and one reviewer agent.");
return;
}
@ -149,6 +163,7 @@ export default function TrackerConfig() {
pollingInterval,
analystAgentId,
developerAgentId,
reviewerAgentId,
filters,
enabled
);
@ -160,6 +175,7 @@ export default function TrackerConfig() {
pollingInterval,
analystAgentId,
developerAgentId,
reviewerAgentId,
filters
);
}
@ -182,9 +198,9 @@ export default function TrackerConfig() {
<div className="text-sm text-gray-500">Loading tracker...</div>
)}
{analystAgents.length === 0 || developerAgents.length === 0 ? (
{analystAgents.length === 0 || developerAgents.length === 0 || reviewerAgents.length === 0 ? (
<div className={noticeClass("warning")}>
You need at least one analyst agent and one developer agent before creating a tracker.
You need at least one analyst agent, one developer agent and one reviewer agent before creating a tracker.
</div>
) : null}
@ -316,6 +332,24 @@ export default function TrackerConfig() {
))}
</select>
</div>
<div>
<label className={labelClass}>
Reviewer agent
</label>
<select
value={reviewerAgentId}
onChange={(e) => setReviewerAgentId(e.target.value)}
required
className={inputClass}
>
<option value="">Select a reviewer agent</option>
{reviewerAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.tool === "codex" ? "Codex" : "Claude Code"})
</option>
))}
</select>
</div>
</div>
{error && (

View file

@ -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
);

View file

@ -4,6 +4,7 @@ const statusClasses: Record<string, string> = {
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",

View file

@ -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<WatchedTracker> {
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<void> {
@ -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,
},

View file

@ -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;