use crate::models::{Adr, TaskEvent}; use crate::state::MemoryState; use std::sync::Arc; #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)] pub struct GitCommitPayload { pub commit: String, #[serde(default)] pub branch: Option, pub message: String, #[serde(default)] pub author: Option, #[serde(default)] pub files: Vec, #[serde(default)] pub repo_name: Option, } #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct ReconciliationResult { pub implemented_adrs: Vec, pub resolved_tech_debts: Vec, pub completed_tasks: Vec, pub unblocked_tasks: Vec, pub updated_milestones: Vec, } /// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102" pub fn extract_adrs(text: &str) -> Vec { let mut adrs = Vec::new(); let lower = text.to_lowercase(); let chars: Vec = text.chars().collect(); let lower_chars: Vec = lower.chars().collect(); let len = chars.len(); let mut i = 0; while i < len { // Look for "adr" if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] { let mut cursor = i + 3; // Skip optional '-', ':', or whitespace while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') { cursor += 1; } // Collect consecutive digits let digit_start = cursor; while cursor < len && chars[cursor].is_ascii_digit() { cursor += 1; } if cursor > digit_start { let num_str: String = chars[digit_start..cursor].iter().collect(); if let Ok(num) = num_str.parse::() { let formatted = format!("ADR-{:04}", num); if !adrs.contains(&formatted) { adrs.push(formatted); } let raw_formatted = format!("ADR-{}", num_str); if !adrs.contains(&raw_formatted) { adrs.push(raw_formatted); } } } i = cursor; } else { i += 1; } } adrs } /// Extracts TechDebt identifiers such as "TD-001", "td-auth-cleanup", "TD: 12" pub fn extract_tech_debts(text: &str) -> Vec { let mut debts = Vec::new(); let lower = text.to_lowercase(); let chars: Vec = text.chars().collect(); let lower_chars: Vec = lower.chars().collect(); let len = chars.len(); let mut i = 0; while i < len { if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] { let mut cursor = i + 2; while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') { cursor += 1; } let start = cursor; while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') { cursor += 1; } if cursor > start { let id_part: String = chars[start..cursor].iter().collect(); let full_id = format!("TD-{}", id_part); if !debts.contains(&full_id) { debts.push(full_id); } if !debts.contains(&id_part) { debts.push(id_part); } } i = cursor; } else { i += 1; } } debts } /// Extracts Task identifiers such as "TASK-123", "task-01", or UUID strings pub fn extract_tasks(text: &str) -> Vec { let mut tasks = Vec::new(); let lower = text.to_lowercase(); let chars: Vec = text.chars().collect(); let lower_chars: Vec = lower.chars().collect(); let len = chars.len(); let mut i = 0; while i < len { if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] { let mut cursor = i + 4; while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') { cursor += 1; } let start = cursor; while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') { cursor += 1; } if cursor > start { let id_part: String = chars[start..cursor].iter().collect(); let full_id = format!("TASK-{}", id_part); if !tasks.contains(&full_id) { tasks.push(full_id); } if !tasks.contains(&id_part) { tasks.push(id_part); } } i = cursor; } else { i += 1; } } tasks } fn adr_numeric_id(id: &str) -> Option { let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect(); digits.parse::().ok() } /// Automatically cascades unblocking to tasks whose dependencies have all completed. pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec { let mut unblocked = Vec::new(); let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone()); state.project.tasks.modify(|tasks| { for t in tasks.iter_mut() { if t.status.eq_ignore_ascii_case("blocked") { if t.dependencies.is_empty() { t.status = "active".to_string(); t.updated_at = crate::handlers::utils::now_secs(); unblocked.push(t.id.clone()); } else { let all_deps_completed = t.dependencies.iter().all(|dep_id| { tasks_snapshot.iter().any(|other| { (other.id == *dep_id || other.title == *dep_id) && !other.is_active() }) }); if all_deps_completed { t.status = "active".to_string(); t.updated_at = crate::handlers::utils::now_secs(); unblocked.push(t.id.clone()); } } } } }); for tid in &unblocked { state.record_activity( "task_update", &format!("Auto-unblocked Task {} (all dependencies met)", tid), Some("active"), ); state.broadcast_task_event(TaskEvent { task_id: tid.clone(), status: "active".to_string(), action: Some("cascade_unblock".to_string()), result: Some(serde_json::json!({ "status": "active" })), error: None, timestamp: crate::handlers::utils::now_secs(), session_id: None, ..Default::default() }); } unblocked } /// Automatically recalculates milestone progress and status based on task completion. pub fn recalculate_milestones(state: &MemoryState) -> Vec { let mut updated = Vec::new(); let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone()); state.project.milestones.modify(|milestones| { for m in milestones.iter_mut() { if m.task_ids.is_empty() { continue; } let total = m.task_ids.len(); let completed = m .task_ids .iter() .filter(|tid| { tasks_snapshot .iter() .any(|t| (t.id == **tid || t.title == **tid) && !t.is_active()) }) .count(); if completed == total { if !m.status.eq_ignore_ascii_case("completed") { m.status = "completed".to_string(); if m.completed_at.is_none() { m.completed_at = Some(crate::handlers::utils::now_secs()); } updated.push(m.id.clone()); } } else if (completed > 0 || tasks_snapshot.iter().any(|t| { m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title) && (t.status == "in_progress" || t.status == "active") })) && !m.status.eq_ignore_ascii_case("in_progress") && !m.status.eq_ignore_ascii_case("completed") { m.status = "in_progress".to_string(); updated.push(m.id.clone()); } } }); for mid in &updated { state.record_activity( "milestone", &format!("Milestone {} status updated via task progress", mid), None, ); } updated } /// Reconciles state deterministically from git commit messages or MCP `log_code_change` calls. pub async fn reconcile_commit_or_code_change( state: &Arc, text: &str, file_path: Option<&str>, git_commit: Option<&str>, git_branch: Option<&str>, ) -> ReconciliationResult { let mut result = ReconciliationResult::default(); let now = crate::handlers::utils::now_secs(); let text_lower = text.to_lowercase(); // 1. Reconcile ADRs let adr_candidates = extract_adrs(text); if !adr_candidates.is_empty() { let mut adrs_to_index: Vec = Vec::new(); state.code.adrs.modify(|adrs| { for adr in adrs.iter_mut() { let matches = adr_candidates.iter().any(|cand| { if adr.id.eq_ignore_ascii_case(cand) { return true; } if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) && n1 == n2 { return true; } false }); if matches && !adr.status.eq_ignore_ascii_case("implemented") { adr.status = "implemented".to_string(); adr.resolved_at = Some(now); if let Some(rev) = git_commit { adr.git_commit = Some(rev.to_string()); } if let Some(br) = git_branch { adr.git_branch = Some(br.to_string()); } result.implemented_adrs.push(adr.id.clone()); adrs_to_index.push(adr.clone()); } } }); if !adrs_to_index.is_empty() { let idx = state.get_search_index().await; for a in &adrs_to_index { drop(idx.index_adr(a)); state.record_activity( "decision", &format!("Auto-implemented ADR {}: {}", a.id, a.title), Some("implemented"), ); } } } // 2. Reconcile Tech Debts let td_candidates = extract_tech_debts(text); let mentions_debt_resolution = text_lower.contains("fix") || text_lower.contains("resolve") || text_lower.contains("refactor") || text_lower.contains("clean") || text_lower.contains("address"); state.code.tech_debts.modify(|debts| { for d in debts.iter_mut() { if d.is_resolved { continue; } let matches_id = td_candidates .iter() .any(|cand| d.id.eq_ignore_ascii_case(cand)); let matches_file = file_path .map(|fp| d.file_path.as_deref() == Some(fp)) .unwrap_or(false) && mentions_debt_resolution && (text_lower.contains("debt") || text_lower.contains("td-")); if matches_id || matches_file { d.is_resolved = true; if let Some(rev) = git_commit { d.git_commit = Some(rev.to_string()); } if let Some(br) = git_branch { d.git_branch = Some(br.to_string()); } result.resolved_tech_debts.push(d.id.clone()); } } }); for tdid in &result.resolved_tech_debts { state.record_activity( "tech_debt", &format!("Auto-resolved TechDebt {} via commit", tdid), Some("resolved"), ); } // 3. Reconcile Tasks let task_candidates = extract_tasks(text); let mentions_task_close = text_lower.contains("fix") || text_lower.contains("close") || text_lower.contains("complete") || text_lower.contains("resolve") || text_lower.contains("finish") || text_lower.contains("implement"); if mentions_task_close && !task_candidates.is_empty() { state.project.tasks.modify(|tasks| { for t in tasks.iter_mut() { if !t.is_active() { continue; } let matches = task_candidates.iter().any(|cand| { t.id.eq_ignore_ascii_case(cand) || cand.trim_start_matches("TASK-").eq_ignore_ascii_case(&t.id) }); if matches { t.status = "completed".to_string(); t.updated_at = now; result.completed_tasks.push(t.id.clone()); } } }); for tid in &result.completed_tasks { state.record_activity( "task_update", &format!("Auto-completed Task {} via commit", tid), Some("completed"), ); state.broadcast_task_event(TaskEvent { task_id: tid.clone(), status: "completed".to_string(), action: Some("commit_reconciliation".to_string()), result: Some(serde_json::json!({ "status": "completed" })), error: None, timestamp: now, session_id: None, ..Default::default() }); } } // 4. Cascade Unblock & Recalculate Milestones if any tasks completed if !result.completed_tasks.is_empty() { result.unblocked_tasks = cascade_unblock_tasks(state); result.updated_milestones = recalculate_milestones(state); } result } #[cfg(test)] mod tests { use super::*; use crate::models::{Milestone, Task}; use tempfile::tempdir; #[test] fn test_extract_adrs() { let text = "feat(api): implement ADR-0102 and ADR-103 with pagination"; let adrs = extract_adrs(text); assert!(adrs.contains(&"ADR-0102".to_string())); assert!(adrs.contains(&"ADR-0103".to_string())); } #[test] fn test_extract_tech_debts() { let text = "refactor: resolve TD-cleanup_old_buffers and TD-002"; let debts = extract_tech_debts(text); assert!(debts.contains(&"TD-cleanup_old_buffers".to_string())); assert!(debts.contains(&"TD-002".to_string())); } #[test] fn test_extract_tasks() { let text = "fix(core): close task-551 and TASK-abc"; let tasks = extract_tasks(text); assert!(tasks.contains(&"TASK-551".to_string())); assert!(tasks.contains(&"TASK-abc".to_string())); } #[tokio::test] async fn test_reconcile_adr_implementation() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); state.code.adrs.modify(|adrs| { adrs.push(Adr { id: "ADR-0102".to_string(), title: "Dynamic Neovim Projection Buffer".to_string(), status: "accepted".to_string(), ..Default::default() }); }); let result = reconcile_commit_or_code_change( &state, "feat: implement ADR-102 projection buffer", None, Some("commit123"), Some("master"), ) .await; assert_eq!(result.implemented_adrs, vec!["ADR-0102"]); let adr = state.code.adrs.read_with(|adrs| adrs[0].clone()); assert_eq!(adr.status, "implemented"); assert_eq!(adr.git_commit.as_deref(), Some("commit123")); assert_eq!(adr.git_branch.as_deref(), Some("master")); assert!(adr.resolved_at.is_some()); } #[tokio::test] async fn test_cascade_unblock_and_milestone_recalculation() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let task1 = Task { id: "task-1".to_string(), title: "Task 1".to_string(), status: "active".to_string(), ..Default::default() }; let task2 = Task { id: "task-2".to_string(), title: "Task 2".to_string(), status: "blocked".to_string(), dependencies: vec!["task-1".to_string()], ..Default::default() }; state.project.tasks.modify(|t| { t.push(task1); t.push(task2); }); let ms = Milestone { id: "ms-1".to_string(), title: "Sprint 1".to_string(), status: "pending".to_string(), task_ids: vec!["task-1".to_string(), "task-2".to_string()], ..Default::default() }; state.project.milestones.modify(|m| m.push(ms)); // Complete task-1 via commit message let result = reconcile_commit_or_code_change( &state, "fix: close task-1 core functionality", None, Some("c1"), Some("master"), ) .await; assert_eq!(result.completed_tasks, vec!["task-1"]); assert_eq!(result.unblocked_tasks, vec!["task-2"]); assert_eq!(result.updated_milestones, vec!["ms-1"]); // Task 2 should now be active let t2 = state.project.tasks.read_with(|t| t[1].clone()); assert_eq!(t2.status, "active"); // Milestone should now be in_progress let m = state.project.milestones.read_with(|m| m[0].clone()); assert_eq!(m.status, "in_progress"); // Now complete task-2 let result2 = reconcile_commit_or_code_change( &state, "fix: close task-2 follow-up", None, Some("c2"), Some("master"), ) .await; assert_eq!(result2.completed_tasks, vec!["task-2"]); let m2 = state.project.milestones.read_with(|m| m[0].clone()); assert_eq!(m2.status, "completed"); assert!(m2.completed_at.is_some()); } }