feat: relancer une étape de ticket et distinguer le statut no-fix
Some checks failed
Quality / quality (push) Failing after 31s

This commit is contained in:
thibaud-lclr 2026-04-22 09:35:45 +02:00
parent 618c30ef84
commit 81bf897348
9 changed files with 506 additions and 217 deletions

View file

@ -1,8 +1,9 @@
use crate::error::AppError; use crate::error::AppError;
use crate::models::ticket::ProcessedTicket; use crate::models::project::Project;
use crate::models::ticket::{ProcessedTicket, RetryFromStep};
use crate::models::worktree::Worktree; use crate::models::worktree::Worktree;
use crate::AppState; use crate::AppState;
use serde::Serialize; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@ -11,6 +12,128 @@ pub struct TicketResult {
pub worktree: Option<Worktree>, pub worktree: Option<Worktree>,
} }
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RetryStep {
Analyst,
Developer,
Review,
}
impl RetryStep {
fn to_model_step(self) -> RetryFromStep {
match self {
RetryStep::Analyst => RetryFromStep::Analyst,
RetryStep::Developer => RetryFromStep::Developer,
RetryStep::Review => RetryFromStep::Review,
}
}
}
fn can_retry_ticket_status(status: &str) -> bool {
matches!(status, "Error" | "Done" | "NoFix" | "Cancelled")
}
fn has_non_empty_text(value: &Option<String>) -> bool {
value
.as_deref()
.map(|text| !text.trim().is_empty())
.unwrap_or(false)
}
fn validate_retry_step_preconditions(ticket: &ProcessedTicket, step: RetryStep) -> Result<(), AppError> {
match step {
RetryStep::Analyst => Ok(()),
RetryStep::Developer => {
if has_non_empty_text(&ticket.analyst_report) {
Ok(())
} else {
Err(AppError::from(
"Cannot retry developer step without an analyst report".to_string(),
))
}
}
RetryStep::Review => {
if !has_non_empty_text(&ticket.analyst_report) {
return Err(AppError::from(
"Cannot retry review step without an analyst report".to_string(),
));
}
if !has_non_empty_text(&ticket.developer_report) {
return Err(AppError::from(
"Cannot retry review step without a developer report".to_string(),
));
}
if ticket
.worktree_path
.as_deref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
&& ticket
.branch_name
.as_deref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
Ok(())
} else {
Err(AppError::from(
"Cannot retry review step without worktree metadata".to_string(),
))
}
}
}
}
fn retry_ticket_internal(
state: &State<'_, AppState>,
ticket_id: &str,
step: RetryStep,
) -> Result<(), AppError> {
let cleanup_target: Option<(Worktree, Option<String>)> = {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let ticket = ProcessedTicket::get_by_id(&conn, ticket_id)?;
if !can_retry_ticket_status(&ticket.status) {
return Err(AppError::from(format!(
"Cannot retry ticket with status '{}'",
ticket.status
)));
}
validate_retry_step_preconditions(&ticket, step)?;
ProcessedTicket::reset_for_retry_from_step(&conn, ticket_id, step.to_model_step())?;
if !matches!(step, RetryStep::Analyst | RetryStep::Developer) {
None
} else if let Some(wt) = Worktree::get_by_ticket_id(&conn, ticket_id)? {
if wt.status == "Active" {
let project = Project::get_by_id(&conn, &ticket.project_id)?;
Some((wt, Some(project.path)))
} else {
Some((wt, None))
}
} else {
None
}
};
if let Some((worktree, Some(path))) = &cleanup_target {
let _ = crate::services::worktree_manager::delete_worktree(
path,
&worktree.path,
&worktree.branch_name,
);
}
if let Some((worktree, _)) = cleanup_target {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
Worktree::delete(&conn, &worktree.id)?;
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn get_ticket_result( pub fn get_ticket_result(
state: State<'_, AppState>, state: State<'_, AppState>,
@ -24,55 +147,16 @@ pub fn get_ticket_result(
#[tauri::command] #[tauri::command]
pub fn retry_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), AppError> { pub fn retry_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), AppError> {
let active_worktree_cleanup: Option<(crate::models::worktree::Worktree, String)> = { retry_ticket_internal(&state, &ticket_id, RetryStep::Analyst)
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")?; #[tauri::command]
conn.execute( pub fn retry_ticket_step(
"UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, review_report = NULL, \ state: State<'_, AppState>,
worktree_path = NULL, branch_name = NULL, processed_at = NULL WHERE id = ?1", ticket_id: String,
rusqlite::params![ticket_id], step: RetryStep,
)?; ) -> Result<(), AppError> {
retry_ticket_internal(&state, &ticket_id, step)
let cleanup_target = if let Some(wt) = Worktree::get_by_ticket_id(&conn, &ticket_id)? {
if wt.status == "Active" {
let project =
crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?;
Some((wt, project.path))
} else {
Some((wt, String::new()))
}
} else {
None
};
cleanup_target
};
if let Some((wt, project_path)) = &active_worktree_cleanup {
if wt.status == "Active" {
let _ = crate::services::worktree_manager::delete_worktree(
project_path,
&wt.path,
&wt.branch_name,
);
}
}
if let Some((wt, _)) = active_worktree_cleanup {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
Worktree::delete(&conn, &wt.id)?;
}
Ok(())
} }
#[tauri::command] #[tauri::command]
@ -81,7 +165,7 @@ pub async fn cancel_ticket(state: State<'_, AppState>, ticket_id: String) -> Res
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?; let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?;
if ticket.status == "Done" || ticket.status == "Cancelled" { if ticket.status == "Done" || ticket.status == "NoFix" || ticket.status == "Cancelled" {
return Err(AppError::from(format!( return Err(AppError::from(format!(
"Cannot cancel ticket with status '{}'", "Cannot cancel ticket with status '{}'",
ticket.status ticket.status
@ -93,7 +177,7 @@ pub async fn cancel_ticket(state: State<'_, AppState>, ticket_id: String) -> Res
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?; let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?;
if ticket.status == "Done" || ticket.status == "Cancelled" { if ticket.status == "Done" || ticket.status == "NoFix" || ticket.status == "Cancelled" {
return Err(AppError::from(format!( return Err(AppError::from(format!(
"Cannot cancel ticket with status '{}'", "Cannot cancel ticket with status '{}'",
ticket.status ticket.status

View file

@ -12,9 +12,7 @@ const TASK_LIST_TRUNCATION_NOTICE: &str =
"\n\n[... contenu tronque pour preserver la fluidite de l'interface ...]"; "\n\n[... contenu tronque pour preserver la fluidite de l'interface ...]";
fn truncate_for_task_list(value: Option<String>, max_bytes: usize) -> Option<String> { fn truncate_for_task_list(value: Option<String>, max_bytes: usize) -> Option<String> {
let Some(content) = value else { let content = value?;
return None;
};
if content.len() <= max_bytes { if content.len() <= max_bytes {
return Some(content); return Some(content);

View file

@ -115,6 +115,7 @@ pub fn run() {
commands::module::set_project_module_enabled, commands::module::set_project_module_enabled,
commands::orchestrator::get_ticket_result, commands::orchestrator::get_ticket_result,
commands::orchestrator::retry_ticket, commands::orchestrator::retry_ticket,
commands::orchestrator::retry_ticket_step,
commands::orchestrator::cancel_ticket, commands::orchestrator::cancel_ticket,
commands::live_agent::create_live_session, commands::live_agent::create_live_session,
commands::live_agent::list_live_sessions, commands::live_agent::list_live_sessions,

View file

@ -30,6 +30,13 @@ pub struct ProjectThroughputStats {
pub avg_lead_time_seconds: Option<f64>, pub avg_lead_time_seconds: Option<f64>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryFromStep {
Analyst,
Developer,
Review,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> { fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
Ok(ProcessedTicket { Ok(ProcessedTicket {
id: row.get(0)?, id: row.get(0)?,
@ -209,13 +216,13 @@ impl ProcessedTicket {
"SELECT "SELECT
COALESCE(SUM( COALESCE(SUM(
CASE CASE
WHEN pt.status NOT IN ('Done', 'Error', 'Cancelled') THEN 1 WHEN pt.status NOT IN ('Done', 'NoFix', 'Error', 'Cancelled') THEN 1
ELSE 0 ELSE 0
END END
), 0) AS backlog_count, ), 0) AS backlog_count,
COALESCE(SUM( COALESCE(SUM(
CASE CASE
WHEN pt.status = 'Done' WHEN pt.status IN ('Done', 'NoFix')
AND pt.processed_at IS NOT NULL AND pt.processed_at IS NOT NULL
AND julianday(pt.processed_at) >= julianday(?2) AND julianday(pt.processed_at) >= julianday(?2)
THEN 1 THEN 1
@ -233,7 +240,7 @@ impl ProcessedTicket {
), 0) AS error_last_24h, ), 0) AS error_last_24h,
AVG( AVG(
CASE CASE
WHEN pt.status IN ('Done', 'Error') WHEN pt.status IN ('Done', 'NoFix', 'Error')
AND pt.processed_at IS NOT NULL AND pt.processed_at IS NOT NULL
AND julianday(pt.processed_at) >= julianday(?2) AND julianday(pt.processed_at) >= julianday(?2)
THEN (julianday(pt.processed_at) - julianday(pt.detected_at)) * 86400.0 THEN (julianday(pt.processed_at) - julianday(pt.detected_at)) * 86400.0
@ -336,6 +343,35 @@ impl ProcessedTicket {
Ok(()) Ok(())
} }
pub fn reset_for_retry_from_step(
conn: &Connection,
id: &str,
step: RetryFromStep,
) -> Result<()> {
let sql = match step {
RetryFromStep::Analyst => {
"UPDATE processed_tickets \
SET status = 'Pending', analyst_report = NULL, developer_report = NULL, review_report = NULL, \
worktree_path = NULL, branch_name = NULL, processed_at = NULL \
WHERE id = ?1"
}
RetryFromStep::Developer => {
"UPDATE processed_tickets \
SET status = 'Pending', developer_report = NULL, review_report = NULL, \
worktree_path = NULL, branch_name = NULL, processed_at = NULL \
WHERE id = ?1"
}
RetryFromStep::Review => {
"UPDATE processed_tickets \
SET status = 'Pending', review_report = NULL, processed_at = NULL \
WHERE id = ?1"
}
};
conn.execute(sql, params![id])?;
Ok(())
}
pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> { pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> {
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
conn.execute( conn.execute(
@ -712,6 +748,58 @@ mod tests {
assert!(updated.processed_at.is_none()); assert!(updated.processed_at.is_none());
} }
#[test]
fn test_reset_for_retry_from_developer_keeps_analysis_and_invalidates_following_steps() {
let (conn, project_id, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::update_status(&conn, &ticket.id, "Done").unwrap();
ProcessedTicket::set_analyst_report(&conn, &ticket.id, "analysis").unwrap();
ProcessedTicket::set_developer_report(&conn, &ticket.id, "dev report").unwrap();
ProcessedTicket::set_review_report(&conn, &ticket.id, "review report").unwrap();
ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap();
ProcessedTicket::reset_for_retry_from_step(&conn, &ticket.id, RetryFromStep::Developer)
.unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.status, "Pending");
assert_eq!(updated.analyst_report.as_deref(), Some("analysis"));
assert!(updated.developer_report.is_none());
assert!(updated.review_report.is_none());
assert!(updated.worktree_path.is_none());
assert!(updated.branch_name.is_none());
assert!(updated.processed_at.is_none());
}
#[test]
fn test_reset_for_retry_from_review_keeps_analysis_and_developer_reports() {
let (conn, project_id, tracker_id) = setup();
let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
.unwrap()
.unwrap();
ProcessedTicket::update_status(&conn, &ticket.id, "Done").unwrap();
ProcessedTicket::set_analyst_report(&conn, &ticket.id, "analysis").unwrap();
ProcessedTicket::set_developer_report(&conn, &ticket.id, "dev report").unwrap();
ProcessedTicket::set_review_report(&conn, &ticket.id, "review report").unwrap();
ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap();
ProcessedTicket::reset_for_retry_from_step(&conn, &ticket.id, RetryFromStep::Review)
.unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.status, "Pending");
assert_eq!(updated.analyst_report.as_deref(), Some("analysis"));
assert_eq!(updated.developer_report.as_deref(), Some("dev report"));
assert!(updated.review_report.is_none());
assert_eq!(updated.worktree_path.as_deref(), Some("/tmp/wt"));
assert_eq!(updated.branch_name.as_deref(), Some("orchai/1"));
assert!(updated.processed_at.is_none());
}
#[test] #[test]
fn test_set_error() { fn test_set_error() {
let (conn, project_id, tracker_id) = setup(); let (conn, project_id, tracker_id) = setup();
@ -801,17 +889,26 @@ mod tests {
&cancelled_detected, &cancelled_detected,
None, None,
); );
insert_ticket_with_timestamps(
&conn,
&project_id,
&tracker_id,
1007,
"NoFix",
&done_detected,
Some(&done_processed),
);
let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap(); let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap();
assert_eq!(stats.backlog_count, 2); assert_eq!(stats.backlog_count, 2);
assert_eq!(stats.done_last_24h, 1); assert_eq!(stats.done_last_24h, 2);
assert_eq!(stats.error_last_24h, 1); assert_eq!(stats.error_last_24h, 1);
let avg = stats let avg = stats
.avg_lead_time_seconds .avg_lead_time_seconds
.expect("avg lead time should be available"); .expect("avg lead time should be available");
assert!((avg - 10800.0).abs() < 1.0); assert!((avg - 9600.0).abs() < 1.0);
} }
#[test] #[test]

View file

@ -28,6 +28,13 @@ pub enum Verdict {
NoFix, NoFix,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProcessStartStep {
Analyst,
Developer,
Review,
}
pub fn build_analyst_prompt(ticket: &ProcessedTicket, project: &Project) -> String { pub fn build_analyst_prompt(ticket: &ProcessedTicket, project: &Project) -> String {
let source_ref = ticket.source_ref.as_deref().unwrap_or("-"); let source_ref = ticket.source_ref.as_deref().unwrap_or("-");
format!( format!(
@ -240,6 +247,12 @@ pub fn parse_verdict(report: &str) -> Verdict {
Verdict::FixNeeded Verdict::FixNeeded
} }
fn has_non_empty_text(value: Option<&str>) -> bool {
value
.map(|text| !text.trim().is_empty())
.unwrap_or(false)
}
fn resolve_path_from_working_dir(working_dir: &Path, path: &str) -> Option<PathBuf> { fn resolve_path_from_working_dir(working_dir: &Path, path: &str) -> Option<PathBuf> {
let trimmed = path.trim(); let trimmed = path.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -805,6 +818,20 @@ async fn process_ticket(
(analyst_agent, developer_agent, reviewer_agent) (analyst_agent, developer_agent, reviewer_agent)
}; };
let start_step = if has_non_empty_text(ticket.analyst_report.as_deref()) {
if has_non_empty_text(ticket.developer_report.as_deref())
&& has_non_empty_text(ticket.worktree_path.as_deref())
&& has_non_empty_text(ticket.branch_name.as_deref())
{
ProcessStartStep::Review
} else {
ProcessStartStep::Developer
}
} else {
ProcessStartStep::Analyst
};
let analyst_report = if start_step == ProcessStartStep::Analyst {
{ {
let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing") ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing")
@ -876,7 +903,7 @@ async fn process_ticket(
{ {
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Done") ProcessedTicket::update_status(&conn, &ticket.id, "NoFix")
.map_err(|e| format!("update_status: {}", e))?; .map_err(|e| format!("update_status: {}", e))?;
} }
let _ = app_handle.emit( let _ = app_handle.emit(
@ -891,6 +918,11 @@ async fn process_ticket(
return Ok(true); return Ok(true);
} }
analyst_report
} else {
ticket.analyst_report.clone().unwrap_or_default()
};
{ {
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
let current = ProcessedTicket::get_by_id(&conn, &ticket.id) let current = ProcessedTicket::get_by_id(&conn, &ticket.id)
@ -900,8 +932,44 @@ async fn process_ticket(
} }
} }
let worktree_result = let (wt_path, branch_name, developer_report) = if start_step == ProcessStartStep::Review {
worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id); if !has_non_empty_text(ticket.worktree_path.as_deref())
|| !has_non_empty_text(ticket.branch_name.as_deref())
{
record_ticket_error(
db,
app_handle,
&project.id,
&ticket.id,
ticket.artifact_id,
"Cannot resume from review step without worktree metadata.",
);
return Ok(true);
}
if !has_non_empty_text(ticket.developer_report.as_deref()) {
record_ticket_error(
db,
app_handle,
&project.id,
&ticket.id,
ticket.artifact_id,
"Cannot resume from review step without developer report.",
);
return Ok(true);
}
(
ticket.worktree_path.clone().unwrap_or_default(),
ticket.branch_name.clone().unwrap_or_default(),
ticket.developer_report.clone().unwrap_or_default(),
)
} else {
let worktree_result = worktree_manager::create_worktree(
&project.path,
&project.base_branch,
ticket.artifact_id,
);
if let Err(e) = &worktree_result { if let Err(e) = &worktree_result {
record_ticket_error( record_ticket_error(
@ -1001,6 +1069,13 @@ async fn process_ticket(
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report) ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report)
.map_err(|e| format!("set_developer_report: {}", e))?; .map_err(|e| format!("set_developer_report: {}", e))?;
}
(wt_path, branch_name, developer_report)
};
{
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
ProcessedTicket::update_status(&conn, &ticket.id, "Reviewing") ProcessedTicket::update_status(&conn, &ticket.id, "Reviewing")
.map_err(|e| format!("update_status: {}", e))?; .map_err(|e| format!("update_status: {}", e))?;
} }

View file

@ -10,6 +10,7 @@ import {
getWorktreeDiff, getWorktreeDiff,
listLocalBranchesForWorktree, listLocalBranchesForWorktree,
retryTicket, retryTicket,
retryTicketStep,
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import { import {
@ -258,6 +259,19 @@ export default function TicketDetail() {
} }
} }
async function handleRetryDeveloper() {
if (!ticketId) return;
setLoading(true);
try {
await retryTicketStep(ticketId, "developer");
await loadData();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}
async function handleApplyFix() { async function handleApplyFix() {
if (!worktree) return; if (!worktree) return;
const normalizedTargetBranch = targetBranch.trim(); const normalizedTargetBranch = targetBranch.trim();
@ -330,6 +344,12 @@ export default function TicketDetail() {
}, },
]; ];
const sourceLink = buildTicketResourceLink(ticket, resourceConfig); const sourceLink = buildTicketResourceLink(ticket, resourceConfig);
const canRetryPipeline =
ticket.status === "Error" ||
ticket.status === "Done" ||
ticket.status === "NoFix" ||
ticket.status === "Cancelled";
const canRetryDeveloperStep = canRetryPipeline && Boolean(ticket.analyst_report?.trim());
return ( return (
<div className={pageClass}> <div className={pageClass}>
@ -348,13 +368,22 @@ export default function TicketDetail() {
</h2> </h2>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{(ticket.status === "Error" || ticket.status === "Done" || ticket.status === "Cancelled") && ( {canRetryDeveloperStep && (
<button
onClick={handleRetryDeveloper}
disabled={loading}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
Retry dev
</button>
)}
{canRetryPipeline && (
<button <button
onClick={handleRetry} onClick={handleRetry}
disabled={loading} disabled={loading}
className={buttonClass({ variant: "primary", size: "sm" })} className={buttonClass({ variant: "primary", size: "sm" })}
> >
Retry Retry all
</button> </button>
)} )}
{(ticket.status === "Pending" || {(ticket.status === "Pending" ||

View file

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

View file

@ -6,6 +6,7 @@ const statusClasses: Record<string, string> = {
Developing: "bg-purple-100 text-purple-700", Developing: "bg-purple-100 text-purple-700",
Reviewing: "bg-indigo-100 text-indigo-700", Reviewing: "bg-indigo-100 text-indigo-700",
Done: "bg-green-100 text-green-700", Done: "bg-green-100 text-green-700",
NoFix: "bg-emerald-100 text-emerald-700",
Error: "bg-red-100 text-red-700", Error: "bg-red-100 text-red-700",
Cancelled: "bg-gray-100 text-gray-500", Cancelled: "bg-gray-100 text-gray-500",
}; };

View file

@ -247,9 +247,13 @@ export async function getRuntimeActivity(projectId: string): Promise<RuntimeActi
export async function getTicketResult(ticketId: string): Promise<TicketResult> { export async function getTicketResult(ticketId: string): Promise<TicketResult> {
return invoke("get_ticket_result", { ticketId }); return invoke("get_ticket_result", { ticketId });
} }
export type TicketRetryStep = "analyst" | "developer" | "review";
export async function retryTicket(ticketId: string): Promise<void> { export async function retryTicket(ticketId: string): Promise<void> {
return invoke("retry_ticket", { ticketId }); return invoke("retry_ticket", { ticketId });
} }
export async function retryTicketStep(ticketId: string, step: TicketRetryStep): Promise<void> {
return invoke("retry_ticket_step", { ticketId, step });
}
export async function cancelTicket(ticketId: string): Promise<void> { export async function cancelTicket(ticketId: string): Promise<void> {
return invoke("cancel_ticket", { ticketId }); return invoke("cancel_ticket", { ticketId });
} }