diff --git a/src/main.rs b/src/main.rs index 5bbf6b0..2f09e06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,8 @@ struct Ticket { created_at: String, claimed_at: Option, completed_at: Option, + #[serde(default)] + last_activity_at: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -76,6 +78,8 @@ struct OrientResponse { rules: Vec, available_context: Vec, recent_history: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + stuck_tickets_warning: Option, } #[derive(Serialize)] @@ -134,16 +138,30 @@ fn orient() -> Response { updated_at: "unknown".to_string(), }); - // Load tickets and find current/suggested + // Load tickets and find current/suggested, also check for stuck tickets let mut current_ticket: Option = None; let mut suggested_ticket: Option = None; let mut best_priority: u8 = 255; + let mut stuck_count: usize = 0; + let now = Utc::now(); + let stuck_threshold_hours: i64 = 24; if let Ok(entries) = fs::read_dir(&tickets_dir) { for entry in entries.flatten() { if let Some(ticket) = read_json::(&entry.path()) { if ticket.status == "claimed" { - current_ticket = Some(ticket); + // Check if this ticket is stuck (claimed > 24 hours ago) + let is_stuck = ticket.claimed_at.as_ref().map_or(false, |claimed_at| { + chrono::DateTime::parse_from_rfc3339(claimed_at) + .map(|t| now.signed_duration_since(t).num_hours() >= stuck_threshold_hours) + .unwrap_or(false) + }); + + if is_stuck { + stuck_count += 1; + } else { + current_ticket = Some(ticket); + } } else if ticket.status == "open" && ticket.priority < best_priority { best_priority = ticket.priority; suggested_ticket = Some(ticket); @@ -179,6 +197,15 @@ fn orient() -> Response { // Load rules from file let rules_data: Rules = read_json(&rules_path).unwrap_or(Rules { rules: vec![] }); + let stuck_tickets_warning = if stuck_count > 0 { + Some(format!( + "{} ticket(s) have been claimed for >{}h and may be stuck. Use GET /tickets/stuck to view them.", + stuck_count, stuck_threshold_hours + )) + } else { + None + }; + let response = OrientResponse { project: ProjectInfo { name: "Egregore".to_string(), @@ -191,6 +218,7 @@ fn orient() -> Response { rules: rules_data.rules, available_context, recent_history, + stuck_tickets_warning, }; json_response(response) @@ -267,6 +295,7 @@ fn create_ticket(request: &Request) -> Response { } let new_id = format!("{:03}", max_id + 1); + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let ticket = Ticket { id: new_id.clone(), title: body.title, @@ -276,9 +305,10 @@ fn create_ticket(request: &Request) -> Response { context: body.context.unwrap_or_default(), acceptance: body.acceptance.unwrap_or_default(), notes: vec![], - created_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + created_at: now.clone(), claimed_at: None, completed_at: None, + last_activity_at: Some(now), }; let path = tickets_dir.join(format!("{}.json", new_id)); @@ -341,8 +371,10 @@ fn claim_ticket(id: &str) -> Response { return error_response(400, &format!("Ticket {} is not open (status: {})", id, ticket.status)); } + let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); ticket.status = "claimed".to_string(); - ticket.claimed_at = Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()); + ticket.claimed_at = Some(timestamp.clone()); + ticket.last_activity_at = Some(timestamp); match write_json(&path, &ticket) { Ok(_) => json_response(ticket), @@ -410,8 +442,84 @@ fn add_ticket_note(id: &str, request: &Request) -> Response { Err(e) => return error_response(400, &format!("Invalid JSON: {}", e)), }; - let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); ticket.notes.push(format!("[{}] {}", timestamp, body.note)); + ticket.last_activity_at = Some(timestamp); + + match write_json(&path, &ticket) { + Ok(_) => json_response(ticket), + Err(e) => error_response(500, &e), + } +} + +fn stuck_tickets(request: &Request) -> Response { + let tickets_dir = PathBuf::from(DATA_DIR).join("tickets"); + + // Parse hours parameter (default 24) + let hours: i64 = request + .get_param("hours") + .and_then(|h| h.parse().ok()) + .unwrap_or(24); + + let now = Utc::now(); + let mut stuck: Vec = vec![]; + + if let Ok(entries) = fs::read_dir(&tickets_dir) { + for entry in entries.flatten() { + if let Some(ticket) = read_json::(&entry.path()) { + if ticket.status == "claimed" { + if let Some(claimed_at) = &ticket.claimed_at { + if let Ok(claimed_time) = chrono::DateTime::parse_from_rfc3339(claimed_at) { + let elapsed = now.signed_duration_since(claimed_time); + if elapsed.num_hours() >= hours { + stuck.push(ticket); + } + } + } + } + } + } + } + + // Sort by claimed_at (oldest first) + stuck.sort_by(|a, b| a.claimed_at.cmp(&b.claimed_at)); + + json_response(json!({ + "stuck_tickets": stuck, + "threshold_hours": hours, + "count": stuck.len() + })) +} + +fn unclaim_ticket(id: &str) -> Response { + let path = PathBuf::from(DATA_DIR).join("tickets").join(format!("{}.json", id)); + + let mut ticket: Ticket = match read_json(&path) { + Some(t) => t, + None => return error_response(404, &format!("Ticket {} not found", id)), + }; + + if ticket.status != "claimed" { + return error_response(400, &format!("Ticket {} is not claimed (status: {})", id, ticket.status)); + } + + // Add automatic note about unclaim + let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let claimed_duration = if let Some(claimed_at) = &ticket.claimed_at { + if let Ok(claimed_time) = chrono::DateTime::parse_from_rfc3339(claimed_at) { + let elapsed = Utc::now().signed_duration_since(claimed_time); + format!(" (was claimed for {} hours)", elapsed.num_hours()) + } else { + String::new() + } + } else { + String::new() + }; + + ticket.notes.push(format!("[{}] UNCLAIMED: Ticket released back to open status{}", timestamp, claimed_duration)); + ticket.status = "open".to_string(); + ticket.last_activity_at = Some(timestamp); + // Preserve claimed_at for historical reference match write_json(&path, &ticket) { Ok(_) => json_response(ticket), @@ -539,12 +647,14 @@ fn handle_request(request: &Request) -> Response { // Tickets (GET) ["/tickets"] => { list_tickets() }, + (GET) ["/tickets/stuck"] => { stuck_tickets(request) }, (POST) ["/tickets"] => { create_ticket(request) }, (GET) ["/tickets/{id}", id: String] => { get_ticket(&id) }, (PATCH) ["/tickets/{id}", id: String] => { update_ticket(&id, request) }, (POST) ["/tickets/{id}/claim", id: String] => { claim_ticket(&id) }, (POST) ["/tickets/{id}/complete", id: String] => { complete_ticket(&id, request) }, (POST) ["/tickets/{id}/note", id: String] => { add_ticket_note(&id, request) }, + (POST) ["/tickets/{id}/unclaim", id: String] => { unclaim_ticket(&id) }, // Context (GET) ["/context"] => { list_context() },