feat: relancer une étape de ticket et distinguer le statut no-fix
Some checks failed
Quality / quality (push) Failing after 31s
Some checks failed
Quality / quality (push) Failing after 31s
This commit is contained in:
parent
618c30ef84
commit
81bf897348
9 changed files with 506 additions and 217 deletions
|
|
@ -1,8 +1,9 @@
|
|||
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::AppState;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -11,6 +12,128 @@ pub struct TicketResult {
|
|||
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]
|
||||
pub fn get_ticket_result(
|
||||
state: State<'_, AppState>,
|
||||
|
|
@ -24,55 +147,16 @@ pub fn get_ticket_result(
|
|||
|
||||
#[tauri::command]
|
||||
pub fn retry_ticket(state: State<'_, AppState>, ticket_id: String) -> Result<(), AppError> {
|
||||
let active_worktree_cleanup: Option<(crate::models::worktree::Worktree, String)> = {
|
||||
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
|
||||
)));
|
||||
retry_ticket_internal(&state, &ticket_id, RetryStep::Analyst)
|
||||
}
|
||||
|
||||
ProcessedTicket::update_status(&conn, &ticket_id, "Pending")?;
|
||||
conn.execute(
|
||||
"UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, review_report = NULL, \
|
||||
worktree_path = NULL, branch_name = NULL, processed_at = NULL WHERE id = ?1",
|
||||
rusqlite::params![ticket_id],
|
||||
)?;
|
||||
|
||||
let cleanup_target = if let Some(wt) = Worktree::get_by_ticket_id(&conn, &ticket_id)? {
|
||||
if wt.status == "Active" {
|
||||
let project =
|
||||
crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?;
|
||||
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]
|
||||
pub fn retry_ticket_step(
|
||||
state: State<'_, AppState>,
|
||||
ticket_id: String,
|
||||
step: RetryStep,
|
||||
) -> Result<(), AppError> {
|
||||
retry_ticket_internal(&state, &ticket_id, step)
|
||||
}
|
||||
|
||||
#[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 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!(
|
||||
"Cannot cancel ticket with 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 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!(
|
||||
"Cannot cancel ticket with status '{}'",
|
||||
ticket.status
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ const TASK_LIST_TRUNCATION_NOTICE: &str =
|
|||
"\n\n[... contenu tronque pour preserver la fluidite de l'interface ...]";
|
||||
|
||||
fn truncate_for_task_list(value: Option<String>, max_bytes: usize) -> Option<String> {
|
||||
let Some(content) = value else {
|
||||
return None;
|
||||
};
|
||||
let content = value?;
|
||||
|
||||
if content.len() <= max_bytes {
|
||||
return Some(content);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ pub fn run() {
|
|||
commands::module::set_project_module_enabled,
|
||||
commands::orchestrator::get_ticket_result,
|
||||
commands::orchestrator::retry_ticket,
|
||||
commands::orchestrator::retry_ticket_step,
|
||||
commands::orchestrator::cancel_ticket,
|
||||
commands::live_agent::create_live_session,
|
||||
commands::live_agent::list_live_sessions,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ pub struct ProjectThroughputStats {
|
|||
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> {
|
||||
Ok(ProcessedTicket {
|
||||
id: row.get(0)?,
|
||||
|
|
@ -209,13 +216,13 @@ impl ProcessedTicket {
|
|||
"SELECT
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN pt.status NOT IN ('Done', 'Error', 'Cancelled') THEN 1
|
||||
WHEN pt.status NOT IN ('Done', 'NoFix', 'Error', 'Cancelled') THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS backlog_count,
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN pt.status = 'Done'
|
||||
WHEN pt.status IN ('Done', 'NoFix')
|
||||
AND pt.processed_at IS NOT NULL
|
||||
AND julianday(pt.processed_at) >= julianday(?2)
|
||||
THEN 1
|
||||
|
|
@ -233,7 +240,7 @@ impl ProcessedTicket {
|
|||
), 0) AS error_last_24h,
|
||||
AVG(
|
||||
CASE
|
||||
WHEN pt.status IN ('Done', 'Error')
|
||||
WHEN pt.status IN ('Done', 'NoFix', 'Error')
|
||||
AND pt.processed_at IS NOT NULL
|
||||
AND julianday(pt.processed_at) >= julianday(?2)
|
||||
THEN (julianday(pt.processed_at) - julianday(pt.detected_at)) * 86400.0
|
||||
|
|
@ -336,6 +343,35 @@ impl ProcessedTicket {
|
|||
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<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
|
|
@ -712,6 +748,58 @@ mod tests {
|
|||
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]
|
||||
fn test_set_error() {
|
||||
let (conn, project_id, tracker_id) = setup();
|
||||
|
|
@ -801,17 +889,26 @@ mod tests {
|
|||
&cancelled_detected,
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
let avg = stats
|
||||
.avg_lead_time_seconds
|
||||
.expect("avg lead time should be available");
|
||||
assert!((avg - 10800.0).abs() < 1.0);
|
||||
assert!((avg - 9600.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ pub enum Verdict {
|
|||
NoFix,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProcessStartStep {
|
||||
Analyst,
|
||||
Developer,
|
||||
Review,
|
||||
}
|
||||
|
||||
pub fn build_analyst_prompt(ticket: &ProcessedTicket, project: &Project) -> String {
|
||||
let source_ref = ticket.source_ref.as_deref().unwrap_or("-");
|
||||
format!(
|
||||
|
|
@ -240,6 +247,12 @@ pub fn parse_verdict(report: &str) -> Verdict {
|
|||
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> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -805,6 +818,20 @@ async fn process_ticket(
|
|||
(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))?;
|
||||
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))?;
|
||||
ProcessedTicket::update_status(&conn, &ticket.id, "Done")
|
||||
ProcessedTicket::update_status(&conn, &ticket.id, "NoFix")
|
||||
.map_err(|e| format!("update_status: {}", e))?;
|
||||
}
|
||||
let _ = app_handle.emit(
|
||||
|
|
@ -891,6 +918,11 @@ async fn process_ticket(
|
|||
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 current = ProcessedTicket::get_by_id(&conn, &ticket.id)
|
||||
|
|
@ -900,8 +932,44 @@ async fn process_ticket(
|
|||
}
|
||||
}
|
||||
|
||||
let worktree_result =
|
||||
worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id);
|
||||
let (wt_path, branch_name, developer_report) = if start_step == ProcessStartStep::Review {
|
||||
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 {
|
||||
record_ticket_error(
|
||||
|
|
@ -1001,6 +1069,13 @@ async fn process_ticket(
|
|||
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
||||
ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report)
|
||||
.map_err(|e| format!("set_developer_report: {}", e))?;
|
||||
}
|
||||
|
||||
(wt_path, branch_name, developer_report)
|
||||
};
|
||||
|
||||
{
|
||||
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
||||
ProcessedTicket::update_status(&conn, &ticket.id, "Reviewing")
|
||||
.map_err(|e| format!("update_status: {}", e))?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getWorktreeDiff,
|
||||
listLocalBranchesForWorktree,
|
||||
retryTicket,
|
||||
retryTicketStep,
|
||||
} from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
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() {
|
||||
if (!worktree) return;
|
||||
const normalizedTargetBranch = targetBranch.trim();
|
||||
|
|
@ -330,6 +344,12 @@ export default function TicketDetail() {
|
|||
},
|
||||
];
|
||||
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 (
|
||||
<div className={pageClass}>
|
||||
|
|
@ -348,13 +368,22 @@ export default function TicketDetail() {
|
|||
</h2>
|
||||
</div>
|
||||
<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
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
className={buttonClass({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
Retry
|
||||
Retry all
|
||||
</button>
|
||||
)}
|
||||
{(ticket.status === "Pending" ||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export default function TicketList() {
|
|||
</div>
|
||||
|
||||
<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
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const statusClasses: Record<string, string> = {
|
|||
Developing: "bg-purple-100 text-purple-700",
|
||||
Reviewing: "bg-indigo-100 text-indigo-700",
|
||||
Done: "bg-green-100 text-green-700",
|
||||
NoFix: "bg-emerald-100 text-emerald-700",
|
||||
Error: "bg-red-100 text-red-700",
|
||||
Cancelled: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -247,9 +247,13 @@ export async function getRuntimeActivity(projectId: string): Promise<RuntimeActi
|
|||
export async function getTicketResult(ticketId: string): Promise<TicketResult> {
|
||||
return invoke("get_ticket_result", { ticketId });
|
||||
}
|
||||
export type TicketRetryStep = "analyst" | "developer" | "review";
|
||||
export async function retryTicket(ticketId: string): Promise<void> {
|
||||
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> {
|
||||
return invoke("cancel_ticket", { ticketId });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue