//! 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. use crate::http::context::AppContext; use crate::http::workflow::create_epic_file; use crate::validation::CreateEpicRequest; 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 req = CreateEpicRequest::from_json(args)?; // Bug 1102: resolve and validate origin BEFORE creating the epic so a // missing-attribution call leaves no half-state behind. let origin = super::build_origin(args)?; let root = ctx.state.get_project_root()?; let success_criteria = req.success_criteria_strings(); let epic_id = create_epic_file( &root, req.name.as_ref(), req.goal.as_str(), req.motivation.as_ref().map(|d| d.as_ref()), req.key_files.as_deref(), if success_criteria.is_empty() { None } else { Some(success_criteria.as_slice()) }, )?; crate::crdt_state::set_origin(&epic_id, &origin); 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 item_type == "epic" in the CRDT register. 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 Some(view) = crate::crdt_state::read_item(sid) else { continue; }; use crate::io::story_metadata::ItemType; if view.item_type() == Some(ItemType::Epic) { epics.push((sid.clone(), item.name.clone())); } if let Some(epic_id) = view.epic() { let is_done = matches!(item.stage, Stage::Done { .. }); members .entry(epic_id.to_string()) .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(crate::db::ContentKey::Story(epic_id)) .ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?; let epic_view = crate::crdt_state::read_item(epic_id) .ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?; use crate::io::story_metadata::ItemType; if epic_view.item_type() != Some(ItemType::Epic) { return Err(format!( "'{epic_id}' is not an epic (item_type: {:?})", epic_view.item_type() )); } // Parse the epic_id argument to a numeric EpicId for comparison. let epic_numeric = crate::crdt_state::EpicId::from_crdt_str(epic_id); // Find member items. let all_items = crate::pipeline_state::read_all_typed(); let mut member_items: Vec = Vec::new(); let mut done = 0usize; for item in &all_items { let sid = &item.story_id.0; let Some(member_view) = crate::crdt_state::read_item(sid) else { continue; }; if member_view.epic() == epic_numeric { // Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class // Stage variants — no more orthogonal boolean flags. let stage_name = item.stage.dir_name(); if matches!(item.stage, Stage::Done { .. }) { done += 1; } // Story 1087: expose pipeline + status alongside the legacy // stage name so epic-show callers can route by column/badge. member_items.push(json!({ "story_id": sid, "name": item.name, "stage": stage_name, "pipeline": item.stage.pipeline().as_str(), "status": item.stage.status().as_str(), })); } } let total = member_items.len(); serde_json::to_string_pretty(&json!({ "epic_id": epic_id, "name": epic_view.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"], "origin": {"kind": "test", "id": "test-suite"} }), &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_create_epic_rejects_grammar_token_in_name() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_epic( &json!({"name": "Epic bad", "goal": "some goal"}), &ctx, ); assert!(result.is_err()); assert!( result.unwrap_err().contains("AntiGrammarToken"), "expected AntiGrammarToken error" ); } #[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", "origin": {"kind": "test", "id": "test-suite"} }), &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::crdt_state::init_for_test(); crate::db::ensure_content_store(); use crate::crdt_state::EpicId; use crate::io::story_metadata::ItemType; // Write a fake epic with the typed CRDT registers (story 933). // Epics use numeric-only story_ids (see create_epic_file). crate::db::write_item_with_content( "9990", "1_backlog", "# Rollup Epic\n\n## Goal\n\nTest\n", crate::db::ItemMeta::named("Rollup Epic"), ); crate::crdt_state::set_item_type("9990", Some(ItemType::Epic)); // Write two member items: one done, one current. crate::db::write_item_with_content( "9991_story_member_done", "5_done", "# Done Member\n", crate::db::ItemMeta::named("Done Member"), ); crate::crdt_state::set_item_type("9991_story_member_done", Some(ItemType::Story)); crate::crdt_state::set_epic("9991_story_member_done", Some(EpicId(9990))); crate::db::write_item_with_content( "9992_story_member_current", "2_current", "# Current Member\n", crate::db::ItemMeta::named("Current Member"), ); crate::crdt_state::set_item_type("9992_story_member_current", Some(ItemType::Story)); crate::crdt_state::set_epic("9992_story_member_current", Some(EpicId(9990))); 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") .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"); } }