554 lines
19 KiB
Rust
554 lines
19 KiB
Rust
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<String>,
|
|
pub message: String,
|
|
#[serde(default)]
|
|
pub author: Option<String>,
|
|
#[serde(default)]
|
|
pub files: Vec<String>,
|
|
#[serde(default)]
|
|
pub repo_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
pub struct ReconciliationResult {
|
|
pub implemented_adrs: Vec<String>,
|
|
pub resolved_tech_debts: Vec<String>,
|
|
pub completed_tasks: Vec<String>,
|
|
pub unblocked_tasks: Vec<String>,
|
|
pub updated_milestones: Vec<String>,
|
|
}
|
|
|
|
/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102"
|
|
pub fn extract_adrs(text: &str) -> Vec<String> {
|
|
let mut adrs = Vec::new();
|
|
let lower = text.to_lowercase();
|
|
let chars: Vec<char> = text.chars().collect();
|
|
let lower_chars: Vec<char> = 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::<u32>() {
|
|
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<String> {
|
|
let mut debts = Vec::new();
|
|
let lower = text.to_lowercase();
|
|
let chars: Vec<char> = text.chars().collect();
|
|
let lower_chars: Vec<char> = 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<String> {
|
|
let mut tasks = Vec::new();
|
|
let lower = text.to_lowercase();
|
|
let chars: Vec<char> = text.chars().collect();
|
|
let lower_chars: Vec<char> = 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<u32> {
|
|
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
|
|
digits.parse::<u32>().ok()
|
|
}
|
|
|
|
/// Automatically cascades unblocking to tasks whose dependencies have all completed.
|
|
pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
|
|
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<String> {
|
|
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<MemoryState>,
|
|
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<Adr> = 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());
|
|
}
|
|
}
|