feat: implement phase 3 agent pipeline and ticket review UI
This commit is contained in:
parent
33c3a4a19f
commit
acd73f682f
20 changed files with 3227 additions and 17 deletions
1477
package-lock.json
generated
1477
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
12
src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
pub mod credential;
|
||||
pub mod orchestrator;
|
||||
pub mod poller;
|
||||
pub mod project;
|
||||
pub mod tracker;
|
||||
pub mod worktree;
|
||||
|
|
|
|||
83
src-tauri/src/commands/orchestrator.rs
Normal file
83
src-tauri/src/commands/orchestrator.rs
Normal 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(())
|
||||
}
|
||||
104
src-tauri/src/commands/worktree.rs
Normal file
104
src-tauri/src/commands/worktree.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ pub mod credential;
|
|||
pub mod project;
|
||||
pub mod ticket;
|
||||
pub mod tracker;
|
||||
pub mod worktree;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
src-tauri/src/models/worktree.rs
Normal file
199
src-tauri/src/models/worktree.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
421
src-tauri/src/services/orchestrator.rs
Normal file
421
src-tauri/src/services/orchestrator.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
274
src-tauri/src/services/worktree_manager.rs
Normal file
274
src-tauri/src/services/worktree_manager.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
323
src/components/tickets/TicketDetail.tsx
Normal file
323
src/components/tickets/TicketDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/components/tickets/TicketList.tsx
Normal file
114
src/components/tickets/TicketList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue