feat: implement phase 3 agent pipeline and ticket review UI

This commit is contained in:
thibaud-leclere 2026-04-14 09:18:11 +02:00
parent 33c3a4a19f
commit acd73f682f
20 changed files with 3227 additions and 17 deletions

1477
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,9 @@
"@tauri-apps/plugin-dialog": "^2.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.14.0"
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",

12
src-tauri/Cargo.lock generated
View file

@ -2411,6 +2411,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tempfile",
"tokio",
"uuid",
]
@ -3551,6 +3552,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@ -4246,6 +4257,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",

View file

@ -27,11 +27,14 @@ uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["time", "sync", "macros"] }
tokio = { version = "1", features = ["time", "sync", "macros", "process", "io-util"] }
aes-gcm = "0.10"
rand = "0.8"
base64 = "0.22"
[dev-dependencies]
tempfile = "3"
[profile.dev]
incremental = true # Compiles your binary in smaller steps.

View file

@ -1,4 +1,6 @@
pub mod credential;
pub mod orchestrator;
pub mod poller;
pub mod project;
pub mod tracker;
pub mod worktree;

View file

@ -0,0 +1,83 @@
use crate::error::AppError;
use crate::models::ticket::ProcessedTicket;
use crate::models::worktree::Worktree;
use crate::AppState;
use serde::Serialize;
use tauri::State;
#[derive(Debug, Clone, Serialize)]
pub struct TicketResult {
pub ticket: ProcessedTicket,
pub worktree: Option<Worktree>,
}
#[tauri::command]
pub fn get_ticket_result(
state: State<'_, AppState>,
ticket_id: String,
) -> Result<TicketResult, AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?;
let worktree = Worktree::get_by_ticket_id(&conn, &ticket_id)?;
Ok(TicketResult { ticket, worktree })
}
#[tauri::command]
pub fn retry_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)?;
if ticket.status != "Error" && ticket.status != "Done" && ticket.status != "Cancelled" {
return Err(AppError::from(format!(
"Cannot retry ticket with status '{}'",
ticket.status
)));
}
ProcessedTicket::update_status(&conn, &ticket_id, "Pending")?;
conn.execute(
"UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, \
worktree_path = NULL, branch_name = NULL, processed_at = NULL WHERE id = ?1",
rusqlite::params![ticket_id],
)?;
if let Some(wt) = Worktree::get_by_ticket_id(&conn, &ticket_id)? {
if wt.status == "Active" {
let project_id = {
let tracker = crate::models::tracker::WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
tracker.project_id
};
let project = crate::models::project::Project::get_by_id(&conn, &project_id)?;
let _ = crate::services::worktree_manager::delete_worktree(
&project.path,
&wt.path,
&wt.branch_name,
);
}
Worktree::delete(&conn, &wt.id)?;
}
Ok(())
}
#[tauri::command]
pub 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)?;
if ticket.status == "Done" || ticket.status == "Cancelled" {
return Err(AppError::from(format!(
"Cannot cancel ticket with status '{}'",
ticket.status
)));
}
ProcessedTicket::update_status(&conn, &ticket_id, "Cancelled")?;
Ok(())
}

View file

@ -0,0 +1,104 @@
use crate::error::AppError;
use crate::models::project::Project;
use crate::models::ticket::ProcessedTicket;
use crate::models::tracker::WatchedTracker;
use crate::models::worktree::Worktree;
use crate::services::worktree_manager;
use crate::AppState;
use tauri::State;
#[tauri::command]
pub fn list_worktrees(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<Worktree>, AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let worktrees = Worktree::list_by_project(&conn, &project_id)?;
Ok(worktrees)
}
#[tauri::command]
pub fn get_worktree_diff(
state: State<'_, AppState>,
worktree_id: String,
) -> Result<String, AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
let project = Project::get_by_id(&conn, &tracker.project_id)?;
drop(conn);
let diff = worktree_manager::get_diff(&project.path, &project.base_branch, &wt.branch_name)
.map_err(AppError::from)?;
Ok(diff)
}
#[tauri::command]
pub fn apply_fix_to_branch(
state: State<'_, AppState>,
worktree_id: String,
target_branch: String,
) -> Result<(), AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
let project = Project::get_by_id(&conn, &tracker.project_id)?;
drop(conn);
worktree_manager::apply_fix(
&project.path,
&project.base_branch,
&wt.branch_name,
&target_branch,
)
.map_err(AppError::from)?;
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
Worktree::set_merged(&conn, &worktree_id, &target_branch)?;
Ok(())
}
#[tauri::command]
pub fn delete_worktree_cmd(
state: State<'_, AppState>,
worktree_id: String,
) -> Result<(), AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
let project = Project::get_by_id(&conn, &tracker.project_id)?;
drop(conn);
worktree_manager::delete_worktree(&project.path, &wt.path, &wt.branch_name)
.map_err(AppError::from)?;
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
Worktree::delete(&conn, &worktree_id)?;
Ok(())
}
#[tauri::command]
pub fn list_local_branches(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<String>, AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let project = Project::get_by_id(&conn, &project_id)?;
drop(conn);
let branches = worktree_manager::list_local_branches(&project.path).map_err(AppError::from)?;
Ok(branches)
}

View file

@ -38,12 +38,18 @@ pub fn run() {
// Start background poller
services::poller::start(
db_arc,
db_arc.clone(),
encryption_key,
http_client,
app.handle().clone(),
);
// Start agent orchestrator
services::orchestrator::start(
db_arc,
app.handle().clone(),
);
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -64,6 +70,14 @@ pub fn run() {
commands::tracker::list_processed_tickets,
commands::poller::manual_poll,
commands::poller::get_queue_status,
commands::orchestrator::get_ticket_result,
commands::orchestrator::retry_ticket,
commands::orchestrator::cancel_ticket,
commands::worktree::list_worktrees,
commands::worktree::get_worktree_diff,
commands::worktree::apply_fix_to_branch,
commands::worktree::delete_worktree_cmd,
commands::worktree::list_local_branches,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

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

View file

@ -35,7 +35,6 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
})
}
#[allow(dead_code)]
const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, artifact_id, artifact_title, artifact_data, \
status, analyst_report, developer_report, worktree_path, branch_name, \
detected_at, processed_at FROM processed_tickets";
@ -92,7 +91,6 @@ impl ProcessedTicket {
Ok(count > 0)
}
#[allow(dead_code)]
pub fn list_by_tracker(conn: &Connection, tracker_id: &str) -> Result<Vec<ProcessedTicket>> {
let sql = format!(
"{} WHERE tracker_id = ?1 ORDER BY detected_at DESC",
@ -117,11 +115,65 @@ impl ProcessedTicket {
rows.collect()
}
#[allow(dead_code)]
pub fn get_by_id(conn: &Connection, id: &str) -> Result<ProcessedTicket> {
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
conn.query_row(&sql, params![id], from_row)
}
pub fn update_status(conn: &Connection, id: &str, status: &str) -> Result<()> {
conn.execute(
"UPDATE processed_tickets SET status = ?1 WHERE id = ?2",
params![status, id],
)?;
Ok(())
}
pub fn set_analyst_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
conn.execute(
"UPDATE processed_tickets SET analyst_report = ?1 WHERE id = ?2",
params![report, id],
)?;
Ok(())
}
pub fn set_developer_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
conn.execute(
"UPDATE processed_tickets SET developer_report = ?1, processed_at = datetime('now') WHERE id = ?2",
params![report, id],
)?;
Ok(())
}
pub fn set_worktree_info(
conn: &Connection,
id: &str,
worktree_path: &str,
branch_name: &str,
) -> Result<()> {
conn.execute(
"UPDATE processed_tickets SET worktree_path = ?1, branch_name = ?2 WHERE id = ?3",
params![worktree_path, branch_name, id],
)?;
Ok(())
}
pub fn list_pending(conn: &Connection) -> Result<Vec<ProcessedTicket>> {
let sql = format!(
"{} WHERE status = 'Pending' ORDER BY detected_at ASC",
SELECT_ALL_COLS
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], from_row)?;
rows.collect()
}
pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> {
conn.execute(
"UPDATE processed_tickets SET status = 'Error', analyst_report = COALESCE(analyst_report, '') || ?1, processed_at = datetime('now') WHERE id = ?2",
params![error_message, id],
)?;
Ok(())
}
}
#[cfg(test)]
@ -239,4 +291,84 @@ mod tests {
assert_eq!(found.artifact_title, "Not Found Bug");
assert_eq!(found.status, "Pending");
}
#[test]
fn test_update_status() {
let (conn, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.status, "Analyzing");
}
#[test]
fn test_set_analyst_report() {
let (conn, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::set_analyst_report(&conn, &ticket.id, "## Report\nAll good.").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.analyst_report.unwrap(), "## Report\nAll good.");
}
#[test]
fn test_set_developer_report() {
let (conn, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::set_developer_report(&conn, &ticket.id, "Fixed in main.rs").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.developer_report.unwrap(), "Fixed in main.rs");
assert!(updated.processed_at.is_some());
}
#[test]
fn test_set_worktree_info() {
let (conn, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.worktree_path.unwrap(), "/tmp/wt");
assert_eq!(updated.branch_name.unwrap(), "orchai/1");
}
#[test]
fn test_list_pending() {
let (conn, tracker_id) = setup();
ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}").unwrap();
ProcessedTicket::insert_if_new(&conn, &tracker_id, 2, "T2", "{}").unwrap();
let pending = ProcessedTicket::list_pending(&conn).unwrap();
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].artifact_id, 1);
assert_eq!(pending[1].artifact_id, 2);
ProcessedTicket::update_status(&conn, &pending[0].id, "Analyzing").unwrap();
let pending2 = ProcessedTicket::list_pending(&conn).unwrap();
assert_eq!(pending2.len(), 1);
assert_eq!(pending2[0].artifact_id, 2);
}
#[test]
fn test_set_error() {
let (conn, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::set_error(&conn, &ticket.id, "CLI timeout after 600s").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.status, "Error");
assert_eq!(updated.analyst_report.unwrap(), "CLI timeout after 600s");
}
}

View file

@ -0,0 +1,199 @@
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Worktree {
pub id: String,
pub ticket_id: String,
pub path: String,
pub branch_name: String,
pub status: String,
pub created_at: String,
pub merged_at: Option<String>,
pub merged_into: Option<String>,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Worktree> {
Ok(Worktree {
id: row.get(0)?,
ticket_id: row.get(1)?,
path: row.get(2)?,
branch_name: row.get(3)?,
status: row.get(4)?,
created_at: row.get(5)?,
merged_at: row.get(6)?,
merged_into: row.get(7)?,
})
}
const SELECT_ALL_COLS: &str = "SELECT id, ticket_id, path, branch_name, status, \
created_at, merged_at, merged_into FROM worktrees";
impl Worktree {
pub fn insert(conn: &Connection, ticket_id: &str, path: &str, branch_name: &str) -> Result<Worktree> {
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO worktrees (id, ticket_id, path, branch_name, status, created_at) \
VALUES (?1, ?2, ?3, ?4, 'Active', ?5)",
params![id, ticket_id, path, branch_name, now],
)?;
Ok(Worktree {
id,
ticket_id: ticket_id.to_string(),
path: path.to_string(),
branch_name: branch_name.to_string(),
status: "Active".to_string(),
created_at: now,
merged_at: None,
merged_into: None,
})
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Worktree> {
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
conn.query_row(&sql, params![id], from_row)
}
pub fn get_by_ticket_id(conn: &Connection, ticket_id: &str) -> Result<Option<Worktree>> {
let sql = format!("{} WHERE ticket_id = ?1", SELECT_ALL_COLS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query_map(params![ticket_id], from_row)?;
match rows.next() {
Some(Ok(w)) => Ok(Some(w)),
Some(Err(e)) => Err(e),
None => Ok(None),
}
}
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<Worktree>> {
let sql = "SELECT w.id, w.ticket_id, w.path, w.branch_name, w.status, \
w.created_at, w.merged_at, w.merged_into \
FROM worktrees w \
JOIN processed_tickets pt ON w.ticket_id = pt.id \
JOIN watched_trackers wt ON pt.tracker_id = wt.id \
WHERE wt.project_id = ?1 \
ORDER BY w.created_at DESC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![project_id], from_row)?;
rows.collect()
}
pub fn set_merged(conn: &Connection, id: &str, target_branch: &str) -> Result<()> {
conn.execute(
"UPDATE worktrees SET status = 'Merged', merged_at = datetime('now'), merged_into = ?1 WHERE id = ?2",
params![target_branch, id],
)?;
Ok(())
}
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
conn.execute("DELETE FROM worktrees WHERE id = ?1", params![id])?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::models::project::Project;
use crate::models::ticket::ProcessedTicket;
use crate::models::tracker::{AgentConfig, WatchedTracker};
fn setup() -> (Connection, String) {
let conn = db::init_in_memory().expect("db init");
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
let agent_config = AgentConfig {
analyst_command: "echo".into(),
analyst_args: vec![],
developer_command: "echo".into(),
developer_args: vec![],
};
let tracker = WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![])
.unwrap();
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker.id, 42, "Bug 42", "{}")
.unwrap()
.unwrap();
(conn, ticket.id)
}
#[test]
fn test_insert_and_get_by_id() {
let (conn, ticket_id) = setup();
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/orchai-42", "orchai/42").unwrap();
assert_eq!(wt.status, "Active");
assert_eq!(wt.branch_name, "orchai/42");
let found = Worktree::get_by_id(&conn, &wt.id).unwrap();
assert_eq!(found.id, wt.id);
assert_eq!(found.ticket_id, ticket_id);
assert_eq!(found.path, "/tmp/orchai-42");
}
#[test]
fn test_get_by_ticket_id() {
let (conn, ticket_id) = setup();
let none = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap();
assert!(none.is_none());
Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
let some = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap();
assert!(some.is_some());
assert_eq!(some.unwrap().ticket_id, ticket_id);
}
#[test]
fn test_list_by_project() {
let conn = db::init_in_memory().expect("db init");
let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap();
let agent_config = AgentConfig {
analyst_command: "echo".into(),
analyst_args: vec![],
developer_command: "echo".into(),
developer_args: vec![],
};
let tracker = WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![])
.unwrap();
let t1 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 1, "T1", "{}")
.unwrap()
.unwrap();
let t2 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 2, "T2", "{}")
.unwrap()
.unwrap();
Worktree::insert(&conn, &t1.id, "/wt1", "orchai/1").unwrap();
Worktree::insert(&conn, &t2.id, "/wt2", "orchai/2").unwrap();
let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap();
assert_eq!(worktrees.len(), 2);
}
#[test]
fn test_set_merged() {
let (conn, ticket_id) = setup();
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
Worktree::set_merged(&conn, &wt.id, "feature/login").unwrap();
let updated = Worktree::get_by_id(&conn, &wt.id).unwrap();
assert_eq!(updated.status, "Merged");
assert_eq!(updated.merged_into.unwrap(), "feature/login");
assert!(updated.merged_at.is_some());
}
#[test]
fn test_delete() {
let (conn, ticket_id) = setup();
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
Worktree::delete(&conn, &wt.id).unwrap();
let result = Worktree::get_by_id(&conn, &wt.id);
assert!(result.is_err());
}
}

View file

@ -1,4 +1,6 @@
pub mod crypto;
pub mod filter_engine;
pub mod orchestrator;
pub mod poller;
pub mod tuleap_client;
pub mod worktree_manager;

View file

@ -0,0 +1,421 @@
use crate::models::project::Project;
use crate::models::ticket::ProcessedTicket;
use crate::models::tracker::WatchedTracker;
use crate::models::worktree::Worktree;
use crate::services::worktree_manager;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::time::{interval, timeout, Duration};
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
FixNeeded,
NoFix,
}
pub fn build_analyst_prompt(ticket: &ProcessedTicket, project: &Project) -> String {
format!(
r#"Tu es un analyste technique. Voici un ticket Tuleap a analyser.
## Ticket
- ID: {artifact_id}
- Titre: {title}
- Donnees: {data}
## Contexte
- Projet: {project_name}
- Repo: {project_path}
- Branche de base: {base_branch}
## Ta mission
1. Analyse le ticket et identifie les fichiers/fonctions concernes
2. Explique techniquement le probleme
3. Evalue si une correction de code est necessaire
4. Produis un rapport structure en markdown
Termine ton rapport par un de ces verdicts sur une ligne separee:
[VERDICT: FIX_NEEDED] si une correction de code est necessaire
[VERDICT: NO_FIX] si aucune correction n'est necessaire"#,
artifact_id = ticket.artifact_id,
title = ticket.artifact_title,
data = ticket.artifact_data,
project_name = project.name,
project_path = project.path,
base_branch = project.base_branch,
)
}
pub fn build_developer_prompt(
ticket: &ProcessedTicket,
project: &Project,
analyst_report: &str,
worktree_path: &str,
) -> String {
format!(
r#"Tu es un developpeur. Tu dois corriger un bug ou implementer une fonctionnalite d'apres l'analyse suivante.
## Rapport d'analyse
{analyst_report}
## Ticket
- ID: {artifact_id}
- Titre: {title}
## Contexte
- Projet: {project_name}
- Repo (worktree): {worktree_path}
- Branche de base: {base_branch}
## Ta mission
1. Implemente la correction dans le code
2. Fais des commits atomiques avec des messages clairs
3. Produis un rapport en markdown decrivant les changements effectues"#,
analyst_report = analyst_report,
artifact_id = ticket.artifact_id,
title = ticket.artifact_title,
project_name = project.name,
worktree_path = worktree_path,
base_branch = project.base_branch,
)
}
pub fn parse_verdict(report: &str) -> Verdict {
for line in report.lines().rev() {
let trimmed = line.trim();
if trimmed.contains("[VERDICT: NO_FIX]") {
return Verdict::NoFix;
}
if trimmed.contains("[VERDICT: FIX_NEEDED]") {
return Verdict::FixNeeded;
}
}
Verdict::FixNeeded
}
pub async fn run_cli_command(
command: &str,
args: &[String],
prompt: &str,
working_dir: &str,
timeout_secs: u64,
app_handle: &AppHandle,
ticket_id: &str,
) -> Result<String, String> {
let mut child = Command::new(command)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.current_dir(working_dir)
.spawn()
.map_err(|e| format!("Failed to spawn '{}': {}", command, e))?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(prompt.as_bytes())
.await
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
}
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
let mut reader = BufReader::new(stdout).lines();
let mut output = String::new();
let read_future = async {
while let Ok(Some(line)) = reader.next_line().await {
let _ = app_handle.emit(
"ticket-processing-progress",
serde_json::json!({
"ticket_id": ticket_id,
"output_chunk": line,
}),
);
output.push_str(&line);
output.push('\n');
}
output
};
let result = timeout(Duration::from_secs(timeout_secs), read_future)
.await
.map_err(|_| format!("CLI command timed out after {}s", timeout_secs))?;
let status = child
.wait()
.await
.map_err(|e| format!("Failed to wait for process: {}", e))?;
if !status.success() {
let code = status.code().unwrap_or(-1);
return Err(format!("CLI command exited with code {}", code));
}
Ok(result)
}
async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) -> Result<bool, String> {
let (ticket, tracker, project) = {
let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
let pending = ProcessedTicket::list_pending(&conn).map_err(|e| format!("list_pending failed: {}", e))?;
let ticket = match pending.into_iter().next() {
Some(t) => t,
None => return Ok(false),
};
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)
.map_err(|e| format!("get tracker failed: {}", e))?;
let project = Project::get_by_id(&conn, &tracker.project_id)
.map_err(|e| format!("get project failed: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing")
.map_err(|e| format!("update_status failed: {}", e))?;
(ticket, tracker, project)
};
let _ = app_handle.emit(
"ticket-processing-started",
serde_json::json!({
"ticket_id": ticket.id,
"step": "analyst",
}),
);
let analyst_prompt = build_analyst_prompt(&ticket, &project);
let analyst_result = run_cli_command(
&tracker.agent_config.analyst_command,
&tracker.agent_config.analyst_args,
&analyst_prompt,
&project.path,
600,
app_handle,
&ticket.id,
)
.await;
let analyst_report = match analyst_result {
Ok(report) => report,
Err(e) => {
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
let _ = app_handle.emit(
"ticket-processing-error",
serde_json::json!({ "ticket_id": ticket.id, "error": e }),
);
return Ok(true);
}
};
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::set_analyst_report(&conn, &ticket.id, &analyst_report)
.map_err(|e| format!("set_analyst_report: {}", e))?;
}
let verdict = parse_verdict(&analyst_report);
if verdict == Verdict::NoFix {
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Done")
.map_err(|e| format!("update_status: {}", e))?;
let _ = app_handle.emit(
"ticket-processing-done",
serde_json::json!({ "ticket_id": ticket.id }),
);
return Ok(true);
}
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
let current = ProcessedTicket::get_by_id(&conn, &ticket.id).map_err(|e| format!("get_by_id: {}", e))?;
if current.status == "Cancelled" {
return Ok(true);
}
}
let (wt_path, branch_name) =
worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id).map_err(|e| {
let conn = db.lock().ok();
if let Some(conn) = conn {
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
}
e
})?;
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::set_worktree_info(&conn, &ticket.id, &wt_path, &branch_name)
.map_err(|e| format!("set_worktree_info: {}", e))?;
Worktree::insert(&conn, &ticket.id, &wt_path, &branch_name)
.map_err(|e| format!("insert worktree: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Developing")
.map_err(|e| format!("update_status: {}", e))?;
}
let _ = app_handle.emit(
"ticket-processing-started",
serde_json::json!({
"ticket_id": ticket.id,
"step": "developer",
}),
);
let developer_prompt = build_developer_prompt(&ticket, &project, &analyst_report, &wt_path);
let developer_result = run_cli_command(
&tracker.agent_config.developer_command,
&tracker.agent_config.developer_args,
&developer_prompt,
&wt_path,
600,
app_handle,
&ticket.id,
)
.await;
let developer_report = match developer_result {
Ok(report) => report,
Err(e) => {
let conn = db.lock().map_err(|e2| format!("DB lock: {}", e2))?;
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
let _ = app_handle.emit(
"ticket-processing-error",
serde_json::json!({ "ticket_id": ticket.id, "error": e }),
);
return Ok(true);
}
};
{
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, "Done")
.map_err(|e| format!("update_status: {}", e))?;
}
let _ = app_handle.emit(
"ticket-processing-done",
serde_json::json!({ "ticket_id": ticket.id }),
);
Ok(true)
}
pub fn start(db: Arc<Mutex<Connection>>, app_handle: AppHandle) {
tokio::spawn(async move {
let mut tick = interval(Duration::from_secs(10));
loop {
tick.tick().await;
match process_ticket(&db, &app_handle).await {
Ok(true) => {
continue;
}
Ok(false) => {}
Err(e) => {
eprintln!("orchestrator: {}", e);
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_analyst_prompt_contains_ticket_info() {
let ticket = ProcessedTicket {
id: "t1".into(),
tracker_id: "tr1".into(),
artifact_id: 42,
artifact_title: "Login crash on empty password".into(),
artifact_data: r#"{"id":42,"title":"Login crash"}"#.into(),
status: "Pending".into(),
analyst_report: None,
developer_report: None,
worktree_path: None,
branch_name: None,
detected_at: "2026-01-01T00:00:00Z".into(),
processed_at: None,
};
let project = Project {
id: "p1".into(),
name: "MyApp".into(),
path: "/home/user/myapp".into(),
cloned_from: None,
base_branch: "stable".into(),
created_at: "2026-01-01T00:00:00Z".into(),
};
let prompt = build_analyst_prompt(&ticket, &project);
assert!(prompt.contains("42"));
assert!(prompt.contains("Login crash on empty password"));
assert!(prompt.contains("MyApp"));
assert!(prompt.contains("/home/user/myapp"));
assert!(prompt.contains("stable"));
assert!(prompt.contains("[VERDICT: FIX_NEEDED]"));
assert!(prompt.contains("[VERDICT: NO_FIX]"));
}
#[test]
fn test_build_developer_prompt_contains_report() {
let ticket = ProcessedTicket {
id: "t1".into(),
tracker_id: "tr1".into(),
artifact_id: 42,
artifact_title: "Login crash".into(),
artifact_data: "{}".into(),
status: "Developing".into(),
analyst_report: None,
developer_report: None,
worktree_path: None,
branch_name: None,
detected_at: "2026-01-01T00:00:00Z".into(),
processed_at: None,
};
let project = Project {
id: "p1".into(),
name: "MyApp".into(),
path: "/home/user/myapp".into(),
cloned_from: None,
base_branch: "main".into(),
created_at: "2026-01-01T00:00:00Z".into(),
};
let prompt = build_developer_prompt(&ticket, &project, "## Bug found in auth.rs", "/tmp/wt");
assert!(prompt.contains("## Bug found in auth.rs"));
assert!(prompt.contains("42"));
assert!(prompt.contains("/tmp/wt"));
}
#[test]
fn test_parse_verdict_fix_needed() {
let report = "## Analysis\nBug found.\n[VERDICT: FIX_NEEDED]\n";
assert_eq!(parse_verdict(report), Verdict::FixNeeded);
}
#[test]
fn test_parse_verdict_no_fix() {
let report = "## Analysis\nThis is a feature request, not a bug.\n[VERDICT: NO_FIX]\n";
assert_eq!(parse_verdict(report), Verdict::NoFix);
}
#[test]
fn test_parse_verdict_missing_defaults_to_fix() {
let report = "## Analysis\nSomething is wrong but I forgot the verdict.";
assert_eq!(parse_verdict(report), Verdict::FixNeeded);
}
#[test]
fn test_parse_verdict_embedded_in_line() {
let report = "Verdict: [VERDICT: NO_FIX] - no code change needed.";
assert_eq!(parse_verdict(report), Verdict::NoFix);
}
}

View file

@ -0,0 +1,274 @@
use std::path::Path;
use std::process::Command;
fn run_git(project_path: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(project_path)
.output()
.map_err(|e| format!("Failed to run git: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("git {} failed: {}", args.join(" "), stderr))
}
}
pub fn create_worktree(
project_path: &str,
base_branch: &str,
artifact_id: i32,
) -> Result<(String, String), String> {
let orchai_dir = Path::new(project_path).join(".orchai").join("worktrees");
std::fs::create_dir_all(&orchai_dir)
.map_err(|e| format!("Failed to create .orchai/worktrees dir: {}", e))?;
let worktree_name = format!("orchai-{}", artifact_id);
let worktree_path = orchai_dir.join(&worktree_name);
let branch_name = format!("orchai/{}", artifact_id);
let wt_path_str = worktree_path.to_str().ok_or("Invalid worktree path")?;
run_git(
project_path,
&["worktree", "add", wt_path_str, "-b", &branch_name, base_branch],
)?;
Ok((wt_path_str.to_string(), branch_name))
}
pub fn get_diff(project_path: &str, base_branch: &str, branch_name: &str) -> Result<String, String> {
let range = format!("{}...{}", base_branch, branch_name);
run_git(project_path, &["diff", &range])
}
pub fn list_commits(
project_path: &str,
base_branch: &str,
branch_name: &str,
) -> Result<Vec<String>, String> {
let range = format!("{}..{}", base_branch, branch_name);
let output = run_git(project_path, &["log", &range, "--format=%H", "--reverse"])?;
Ok(output
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
pub fn apply_fix(
project_path: &str,
base_branch: &str,
branch_name: &str,
target_branch: &str,
) -> Result<(), String> {
let commits = list_commits(project_path, base_branch, branch_name)?;
if commits.is_empty() {
return Err("No commits to cherry-pick".to_string());
}
let current = run_git(project_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let current = current.trim();
run_git(project_path, &["checkout", target_branch])?;
let mut cherry_args = vec!["cherry-pick"];
let commit_refs: Vec<&str> = commits.iter().map(|s| s.as_str()).collect();
cherry_args.extend(&commit_refs);
let result = run_git(project_path, &cherry_args);
if let Err(e) = &result {
let _ = run_git(project_path, &["cherry-pick", "--abort"]);
let _ = run_git(project_path, &["checkout", current]);
return Err(format!("Cherry-pick failed (conflict?): {}", e));
}
run_git(project_path, &["checkout", current])?;
Ok(())
}
pub fn delete_worktree(
project_path: &str,
worktree_path: &str,
branch_name: &str,
) -> Result<(), String> {
run_git(project_path, &["worktree", "remove", worktree_path, "--force"])?;
let _ = run_git(project_path, &["branch", "-D", branch_name]);
Ok(())
}
pub fn list_local_branches(project_path: &str) -> Result<Vec<String>, String> {
let output = run_git(project_path, &["branch", "--format=%(refname:short)"])?;
Ok(output
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn setup_test_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().to_str().unwrap();
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.unwrap();
std::fs::write(dir.path().join("README.md"), "# Test").unwrap();
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()
.unwrap();
dir
}
#[test]
fn test_create_worktree() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
let (wt_path, branch) = create_worktree(path, "main", 42).unwrap();
assert!(wt_path.contains("orchai-42"));
assert_eq!(branch, "orchai/42");
assert!(Path::new(&wt_path).exists());
}
#[test]
fn test_get_diff_empty() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
let (_, branch) = create_worktree(path, "main", 1).unwrap();
let diff = get_diff(path, "main", &branch).unwrap();
assert!(diff.is_empty(), "No changes yet, diff should be empty");
}
#[test]
fn test_get_diff_with_changes() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
let (wt_path, branch) = create_worktree(path, "main", 2).unwrap();
std::fs::write(Path::new(&wt_path).join("fix.txt"), "fixed").unwrap();
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
Command::new("git")
.args(["commit", "-m", "fix"])
.current_dir(&wt_path)
.output()
.unwrap();
let diff = get_diff(path, "main", &branch).unwrap();
assert!(diff.contains("fix.txt"));
assert!(diff.contains("+fixed"));
}
#[test]
fn test_list_commits() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
let (wt_path, branch) = create_worktree(path, "main", 3).unwrap();
std::fs::write(Path::new(&wt_path).join("a.txt"), "a").unwrap();
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
Command::new("git")
.args(["commit", "-m", "first"])
.current_dir(&wt_path)
.output()
.unwrap();
std::fs::write(Path::new(&wt_path).join("b.txt"), "b").unwrap();
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
Command::new("git")
.args(["commit", "-m", "second"])
.current_dir(&wt_path)
.output()
.unwrap();
let commits = list_commits(path, "main", &branch).unwrap();
assert_eq!(commits.len(), 2);
}
#[test]
fn test_list_local_branches() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
create_worktree(path, "main", 10).unwrap();
let branches = list_local_branches(path).unwrap();
assert!(branches.contains(&"main".to_string()));
assert!(branches.contains(&"orchai/10".to_string()));
}
#[test]
fn test_delete_worktree() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
let (wt_path, branch) = create_worktree(path, "main", 99).unwrap();
assert!(Path::new(&wt_path).exists());
delete_worktree(path, &wt_path, &branch).unwrap();
assert!(!Path::new(&wt_path).exists());
let branches = list_local_branches(path).unwrap();
assert!(!branches.contains(&"orchai/99".to_string()));
}
#[test]
fn test_apply_fix() {
let dir = setup_test_repo();
let path = dir.path().to_str().unwrap();
Command::new("git")
.args(["branch", "feature/test"])
.current_dir(path)
.output()
.unwrap();
let (wt_path, branch) = create_worktree(path, "main", 7).unwrap();
std::fs::write(Path::new(&wt_path).join("fix.txt"), "the fix").unwrap();
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
Command::new("git")
.args(["commit", "-m", "apply fix"])
.current_dir(&wt_path)
.output()
.unwrap();
apply_fix(path, "main", &branch, "feature/test").unwrap();
Command::new("git")
.args(["checkout", "feature/test"])
.current_dir(path)
.output()
.unwrap();
assert!(Path::new(path).join("fix.txt").exists());
Command::new("git")
.args(["checkout", "main"])
.current_dir(path)
.output()
.unwrap();
}
}

View file

@ -3,6 +3,8 @@ import AppLayout from "./components/layout/AppLayout";
import ProjectForm from "./components/projects/ProjectForm";
import ProjectDashboard from "./components/projects/ProjectDashboard";
import SettingsPage from "./components/settings/SettingsPage";
import TicketDetail from "./components/tickets/TicketDetail";
import TicketList from "./components/tickets/TicketList";
import TrackerConfig from "./components/trackers/TrackerConfig";
function EmptyState() {
@ -21,8 +23,10 @@ function App() {
<Route index element={<EmptyState />} />
<Route path="/projects/new" element={<ProjectForm />} />
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
<Route path="/projects/:projectId/tickets" element={<TicketList />} />
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />
<Route path="/tickets/:ticketId" element={<TicketDetail />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View file

@ -102,14 +102,25 @@ export default function ProjectDashboard() {
</div>
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Recent Tickets</h3>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Recent Tickets</h3>
{tickets.length > 0 && (
<Link
to={`/projects/${project.id}/tickets`}
className="text-sm text-blue-600 hover:underline"
>
View all ({tickets.length})
</Link>
)}
</div>
{recentTickets.length === 0 ? (
<div className="text-sm text-gray-400">No tickets processed yet.</div>
) : (
<div className="space-y-3">
<div className="mt-4 space-y-3">
{recentTickets.map((ticket) => (
<div
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
>
<div className="flex-1 min-w-0">
@ -123,7 +134,7 @@ export default function ProjectDashboard() {
>
{ticket.status}
</span>
</div>
</Link>
))}
</div>
)}

View file

@ -0,0 +1,323 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
applyFixToBranch,
cancelTicket,
deleteWorktreeCmd,
getTicketResult,
getWorktreeDiff,
retryTicket,
} from "../../lib/api";
import type { ProcessedTicket, Worktree } from "../../lib/types";
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Analyzing":
return "bg-blue-100 text-blue-700";
case "Developing":
return "bg-purple-100 text-purple-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
function DiffViewer({ diff }: { diff: string }) {
if (!diff) {
return <div className="py-4 text-center text-sm text-gray-400">No changes detected.</div>;
}
const lines = diff.split("\n");
return (
<pre className="max-h-[600px] overflow-auto rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
{lines.map((line, i) => {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "text-gray-400";
else if (line.startsWith("+")) cls = "bg-green-900/20 text-green-400";
else if (line.startsWith("-")) cls = "bg-red-900/20 text-red-400";
else if (line.startsWith("@@")) cls = "text-blue-400";
else if (line.startsWith("diff ")) cls = "font-bold text-yellow-400";
return (
<div key={i} className={cls}>
{line}
</div>
);
})}
</pre>
);
}
export default function TicketDetail() {
const { ticketId } = useParams();
const navigate = useNavigate();
const [ticket, setTicket] = useState<ProcessedTicket | null>(null);
const [worktree, setWorktree] = useState<Worktree | null>(null);
const [diff, setDiff] = useState<string | null>(null);
const [targetBranch, setTargetBranch] = useState("");
const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function loadData() {
if (!ticketId) return;
try {
const result = await getTicketResult(ticketId);
setTicket(result.ticket);
setWorktree(result.worktree);
if (result.ticket.developer_report) setTab("developer");
else if (result.ticket.analyst_report) setTab("analyst");
if (result.worktree && result.worktree.status === "Active") {
try {
const d = await getWorktreeDiff(result.worktree.id);
setDiff(d);
} catch {
setDiff(null);
}
} else {
setDiff(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
useEffect(() => {
loadData();
}, [ticketId]);
async function handleRetry() {
if (!ticketId) return;
setLoading(true);
try {
await retryTicket(ticketId);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
setLoading(false);
}
async function handleCancel() {
if (!ticketId) return;
setLoading(true);
try {
await cancelTicket(ticketId);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
setLoading(false);
}
async function handleApplyFix() {
if (!worktree || !targetBranch) return;
setLoading(true);
setError("");
try {
await applyFixToBranch(worktree.id, targetBranch);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
setLoading(false);
}
async function handleDeleteWorktree() {
if (!worktree) return;
if (!window.confirm("Delete this worktree and its branch?")) return;
setLoading(true);
try {
await deleteWorktreeCmd(worktree.id);
setWorktree(null);
setDiff(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
setLoading(false);
}
if (!ticket) {
return <div className="p-8 text-gray-400">Loading...</div>;
}
const tabs = [
{ key: "info" as const, label: "Info" },
{
key: "analyst" as const,
label: "Analyst Report",
disabled: !ticket.analyst_report,
},
{
key: "developer" as const,
label: "Developer Report",
disabled: !ticket.developer_report,
},
{ key: "diff" as const, label: "Diff", disabled: !diff && !worktree },
];
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<button onClick={() => navigate(-1)} className="mb-1 text-sm text-blue-600 hover:underline">
Back
</button>
<h2 className="flex items-center gap-3 text-xl font-bold">
<span className="font-mono text-base text-gray-400">#{ticket.artifact_id}</span>
{ticket.artifact_title}
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
</h2>
</div>
<div className="flex gap-2">
{(ticket.status === "Error" || ticket.status === "Done" || ticket.status === "Cancelled") && (
<button
onClick={handleRetry}
disabled={loading}
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Retry
</button>
)}
{(ticket.status === "Pending" ||
ticket.status === "Analyzing" ||
ticket.status === "Developing") && (
<button
onClick={handleCancel}
disabled={loading}
className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
</div>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
)}
<div className="mb-6 flex gap-1 border-b border-gray-200">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
disabled={t.disabled}
className={`-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key
? "border-blue-600 text-blue-600"
: t.disabled
? "cursor-not-allowed border-transparent text-gray-300"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "info" && (
<div className="space-y-4">
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
<div>
<span className="text-sm text-gray-500">Status:</span>
<span className="ml-2 text-sm">{ticket.status}</span>
</div>
<div>
<span className="text-sm text-gray-500">Detected:</span>
<span className="ml-2 text-sm">{new Date(ticket.detected_at).toLocaleString()}</span>
</div>
{ticket.processed_at && (
<div>
<span className="text-sm text-gray-500">Processed:</span>
<span className="ml-2 text-sm">{new Date(ticket.processed_at).toLocaleString()}</span>
</div>
)}
{worktree && (
<div>
<span className="text-sm text-gray-500">Worktree:</span>
<span className="ml-2 font-mono text-sm">{worktree.branch_name}</span>
<span
className={`ml-2 rounded-full px-2 py-0.5 text-xs ${
worktree.status === "Active"
? "bg-green-100 text-green-700"
: worktree.status === "Merged"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}
>
{worktree.status}
</span>
</div>
)}
</div>
{worktree && worktree.status === "Active" && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3>
<div className="mb-3 flex items-center gap-2">
<input
type="text"
placeholder="Target branch (e.g. feature/login)"
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleApplyFix}
disabled={loading || !targetBranch}
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
Apply fix
</button>
</div>
<button
onClick={handleDeleteWorktree}
disabled={loading}
className="text-sm text-red-600 hover:underline"
>
Delete worktree
</button>
</div>
)}
{worktree && worktree.status === "Merged" && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
Fix applied to branch: {worktree.merged_into}
</div>
)}
</div>
)}
{tab === "analyst" && ticket.analyst_report && (
<div className="prose prose-sm max-w-none rounded-lg border border-gray-200 bg-white p-6">
<Markdown remarkPlugins={[remarkGfm]}>{ticket.analyst_report}</Markdown>
</div>
)}
{tab === "developer" && ticket.developer_report && (
<div className="prose prose-sm max-w-none rounded-lg border border-gray-200 bg-white p-6">
<Markdown remarkPlugins={[remarkGfm]}>{ticket.developer_report}</Markdown>
</div>
)}
{tab === "diff" && <DiffViewer diff={diff || ""} />}
</div>
);
}

View file

@ -0,0 +1,114 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getProject, listProcessedTickets } from "../../lib/api";
import type { ProcessedTicket, Project } from "../../lib/types";
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Analyzing":
return "bg-blue-100 text-blue-700";
case "Developing":
return "bg-purple-100 text-purple-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
export default function TicketList() {
const { projectId } = useParams();
const [project, setProject] = useState<Project | null>(null);
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
const [filter, setFilter] = useState<string>("all");
useEffect(() => {
if (!projectId) return;
Promise.all([getProject(projectId), listProcessedTickets(projectId)]).then(
([proj, tkts]) => {
setProject(proj);
setTickets(tkts);
}
);
}, [projectId]);
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<Link to={`/projects/${projectId}`} className="text-sm text-blue-600 hover:underline">
{project?.name}
</Link>
<h2 className="text-xl font-bold">Processed Tickets</h2>
</div>
</div>
<div className="mb-4 flex gap-2">
{["all", "Pending", "Analyzing", "Developing", "Done", "Error"].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`rounded px-3 py-1 text-sm ${
filter === s
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{s === "all" ? "All" : s}
{s !== "all" && (
<span className="ml-1 text-xs opacity-60">
({tickets.filter((t) => t.status === s).length})
</span>
)}
</button>
))}
</div>
{filtered.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400">No tickets found.</div>
) : (
<div className="space-y-2">
{filtered.map((ticket) => (
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
className="block rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">#{ticket.artifact_id}</span>
<span className="truncate text-sm font-medium">{ticket.artifact_title}</span>
</div>
<div className="mt-1 text-xs text-gray-400">
{new Date(ticket.detected_at).toLocaleString()}
{ticket.processed_at && (
<span className="ml-2">
Processed: {new Date(ticket.processed_at).toLocaleString()}
</span>
)}
</div>
</div>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -7,6 +7,8 @@ import type {
WatchedTracker,
TrackerField,
ProcessedTicket,
Worktree,
TicketResult,
} from "./types";
export async function createProject(
@ -82,3 +84,31 @@ export async function manualPoll(trackerId: string): Promise<ProcessedTicket[]>
export async function getQueueStatus(projectId: string): Promise<ProcessedTicket[]> {
return invoke("get_queue_status", { projectId });
}
// Orchestrator
export async function getTicketResult(ticketId: string): Promise<TicketResult> {
return invoke("get_ticket_result", { ticketId });
}
export async function retryTicket(ticketId: string): Promise<void> {
return invoke("retry_ticket", { ticketId });
}
export async function cancelTicket(ticketId: string): Promise<void> {
return invoke("cancel_ticket", { ticketId });
}
// Worktrees
export async function listWorktrees(projectId: string): Promise<Worktree[]> {
return invoke("list_worktrees", { projectId });
}
export async function getWorktreeDiff(worktreeId: string): Promise<string> {
return invoke("get_worktree_diff", { worktreeId });
}
export async function applyFixToBranch(worktreeId: string, targetBranch: string): Promise<void> {
return invoke("apply_fix_to_branch", { worktreeId, targetBranch });
}
export async function deleteWorktreeCmd(worktreeId: string): Promise<void> {
return invoke("delete_worktree_cmd", { worktreeId });
}
export async function listLocalBranches(projectId: string): Promise<string[]> {
return invoke("list_local_branches", { projectId });
}

View file

@ -69,3 +69,19 @@ export interface ProcessedTicket {
detected_at: string;
processed_at: string | null;
}
export interface Worktree {
id: string;
ticket_id: string;
path: string;
branch_name: string;
status: string;
created_at: string;
merged_at: string | null;
merged_into: string | null;
}
export interface TicketResult {
ticket: ProcessedTicket;
worktree: Worktree | null;
}