Files
mcp-memory/server/src/handlers/reconciliation.rs
T

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());
}
}