//! Epic work-item MCP tools — create, list, and show epics. //! //! Epics are shared-context containers that group related stories, bugs, spikes, //! and refactors. They are not pipeline-driven but provide authoritative context //! injected into agent prompts for all member work items. // Epic mechanism (item_type, epic link) has no CRDT register yet — story 933. // parse_front_matter calls here are wrapped in `yaml_residue` so they're // grep-findable until 933 lands. use crate::db::yaml_legacy::{parse_front_matter, yaml_residue}; use crate::http::context::AppContext; use crate::http::workflow::create_epic_file; use serde_json::{Value, json}; /// Create a new epic and store it in the CRDT items list. pub(crate) fn tool_create_epic(args: &Value, ctx: &AppContext) -> Result { let name = args .get("name") .and_then(|v| v.as_str()) .ok_or("Missing required argument: name")?; let goal = args .get("goal") .and_then(|v| v.as_str()) .ok_or("Missing required argument: goal")?; let motivation = args.get("motivation").and_then(|v| v.as_str()); let key_files = args.get("key_files").and_then(|v| v.as_str()); let success_criteria: Option> = args .get("success_criteria") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(str::to_string)) .collect() }); let root = ctx.state.get_project_root()?; let epic_id = create_epic_file( &root, name, goal, motivation, key_files, success_criteria.as_deref(), )?; Ok(format!("Created epic: {epic_id}")) } /// List all epics with member work item counts and `n/m done` rollup. pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result { use crate::pipeline_state::Stage; let all_items = crate::pipeline_state::read_all_typed(); // Collect epics: items with type == "epic". let mut epics: Vec<(String, String)> = Vec::new(); // (id, name) // Collect member items: map from epic_id → list of (story_id, is_done). let mut members: std::collections::HashMap> = std::collections::HashMap::new(); for item in &all_items { let sid = &item.story_id.0; let content = match crate::db::read_content(sid) { Some(c) => c, None => continue, }; let meta = match yaml_residue(parse_front_matter(&content)) { Ok(m) => m, Err(_) => continue, }; if meta.item_type.as_deref() == Some("epic") { epics.push((sid.clone(), item.name.clone())); } if let Some(epic_id) = meta.epic { let is_done = matches!(item.stage, Stage::Done { .. }); members .entry(epic_id) .or_default() .push((sid.clone(), is_done)); } } epics.sort_by(|a, b| a.0.cmp(&b.0)); let result: Vec = epics .iter() .map(|(id, name)| { let member_list = members.get(id).cloned().unwrap_or_default(); let total = member_list.len(); let done = member_list.iter().filter(|(_, d)| *d).count(); json!({ "epic_id": id, "name": name, "members_total": total, "members_done": done, "rollup": format!("{done}/{total} done"), }) }) .collect(); serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}")) } /// Show details for a single epic: content and member work items with their stages. pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result { use crate::pipeline_state::Stage; let epic_id = args .get("epic_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: epic_id")?; let content = crate::db::read_content(epic_id) .ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?; let meta = yaml_residue(parse_front_matter(&content)) .map_err(|e| format!("Failed to parse epic front matter: {e}"))?; if meta.item_type.as_deref() != Some("epic") { return Err(format!( "'{epic_id}' is not an epic (type: {:?})", meta.item_type )); } // Find member items. let all_items = crate::pipeline_state::read_all_typed(); let mut member_items: Vec = Vec::new(); for item in &all_items { let sid = &item.story_id.0; let member_content = match crate::db::read_content(sid) { Some(c) => c, None => continue, }; let member_meta = match yaml_residue(parse_front_matter(&member_content)) { Ok(m) => m, Err(_) => continue, }; if member_meta.epic.as_deref() == Some(epic_id) { let stage_name = match &item.stage { Stage::Upcoming | Stage::Backlog => "backlog", Stage::Coding => "current", Stage::Qa => "qa", Stage::Merge { .. } => "merge", Stage::Done { .. } => "done", Stage::Archived { .. } => "archived", Stage::MergeFailure { .. } => "merge_failure", Stage::Frozen { .. } => "frozen", Stage::Blocked { .. } => "blocked", }; member_items.push(json!({ "story_id": sid, "name": item.name, "stage": stage_name, })); } } let total = member_items.len(); let done = member_items.iter().filter(|i| i["stage"] == "done").count(); serde_json::to_string_pretty(&json!({ "epic_id": epic_id, "name": meta.name, "content": content, "members": member_items, "rollup": format!("{done}/{total} done"), })) .map_err(|e| format!("Serialization error: {e}")) } #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::test_ctx; use serde_json::json; #[test] fn tool_create_epic_creates_epic_and_returns_id() { let tmp = tempfile::tempdir().unwrap(); crate::db::ensure_content_store(); let ctx = test_ctx(tmp.path()); let result = tool_create_epic( &json!({ "name": "My Test Epic", "goal": "Achieve something great", "motivation": "Because it matters", "success_criteria": ["All stories done", "Tests pass"] }), &ctx, ); assert!(result.is_ok(), "expected ok: {result:?}"); let msg = result.unwrap(); assert!(msg.contains("Created epic:"), "unexpected msg: {msg}"); } #[test] fn tool_create_epic_missing_name_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_epic(&json!({"goal": "Achieve something"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("name")); } #[test] fn tool_create_epic_missing_goal_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_epic(&json!({"name": "My Epic"}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("goal")); } #[test] fn tool_list_epics_includes_created_epic() { let tmp = tempfile::tempdir().unwrap(); crate::db::ensure_content_store(); let ctx = test_ctx(tmp.path()); // Create an epic. tool_create_epic( &json!({"name": "List Epics Test Epic", "goal": "Testing list"}), &ctx, ) .unwrap(); let result = tool_list_epics(&ctx); assert!(result.is_ok(), "expected ok: {result:?}"); let parsed: Vec = serde_json::from_str(&result.unwrap()).unwrap(); assert!( parsed.iter().any(|e| e["name"] == "List Epics Test Epic"), "expected epic in list: {parsed:?}" ); } #[test] fn tool_list_epics_shows_member_rollup() { crate::db::ensure_content_store(); // Write a fake epic. crate::db::write_item_with_content( "9990_epic_rollup", "1_backlog", "---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n", crate::db::ItemMeta::from_yaml( "---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n", ), ); // Write two member items: one done, one current. crate::db::write_item_with_content( "9991_story_member_done", "5_done", "---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n", crate::db::ItemMeta::from_yaml( "---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n", ), ); crate::db::write_item_with_content( "9992_story_member_current", "2_current", "---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n", crate::db::ItemMeta::from_yaml( "---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n", ), ); let tmp = tempfile::tempdir().unwrap(); let ctx = crate::http::test_helpers::test_ctx(tmp.path()); let result = tool_list_epics(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); let epic = parsed .iter() .find(|e| e["epic_id"] == "9990_epic_rollup") .expect("expected rollup epic in list"); assert_eq!(epic["members_total"], 2, "two members expected"); assert_eq!(epic["members_done"], 1, "one done member expected"); assert_eq!(epic["rollup"], "1/2 done"); } }