2026-04-29 21:41:44 +00:00
|
|
|
//! 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 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<String, String> {
|
|
|
|
|
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<Vec<String>> = 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<String, String> {
|
|
|
|
|
use crate::pipeline_state::Stage;
|
|
|
|
|
|
|
|
|
|
let all_items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
|
2026-05-12 19:58:43 +01:00
|
|
|
// Collect epics: items with item_type == "epic" in the CRDT register.
|
2026-04-29 21:41:44 +00:00
|
|
|
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<String, Vec<(String, bool)>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
|
|
|
|
|
for item in &all_items {
|
|
|
|
|
let sid = &item.story_id.0;
|
2026-05-12 19:58:43 +01:00
|
|
|
let Some(view) = crate::crdt_state::read_item(sid) else {
|
|
|
|
|
continue;
|
2026-04-29 21:41:44 +00:00
|
|
|
};
|
|
|
|
|
|
2026-05-13 07:54:50 +00:00
|
|
|
use crate::io::story_metadata::ItemType;
|
|
|
|
|
if view.item_type() == Some(ItemType::Epic) {
|
2026-04-29 21:41:44 +00:00
|
|
|
epics.push((sid.clone(), item.name.clone()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 19:58:43 +01:00
|
|
|
if let Some(epic_id) = view.epic() {
|
2026-04-29 21:41:44 +00:00
|
|
|
let is_done = matches!(item.stage, Stage::Done { .. });
|
|
|
|
|
members
|
2026-05-12 19:58:43 +01:00
|
|
|
.entry(epic_id.to_string())
|
2026-04-29 21:41:44 +00:00
|
|
|
.or_default()
|
|
|
|
|
.push((sid.clone(), is_done));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
epics.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
|
|
|
|
|
|
let result: Vec<Value> = 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<String, String> {
|
|
|
|
|
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")?;
|
|
|
|
|
|
2026-05-13 11:22:57 +00:00
|
|
|
let content = crate::db::read_content(crate::db::ContentKey::Story(epic_id))
|
2026-04-29 21:41:44 +00:00
|
|
|
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
|
|
|
|
|
|
2026-05-12 19:58:43 +01:00
|
|
|
let epic_view = crate::crdt_state::read_item(epic_id)
|
|
|
|
|
.ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?;
|
2026-04-29 21:41:44 +00:00
|
|
|
|
2026-05-13 07:54:50 +00:00
|
|
|
use crate::io::story_metadata::ItemType;
|
|
|
|
|
if epic_view.item_type() != Some(ItemType::Epic) {
|
2026-04-29 21:41:44 +00:00
|
|
|
return Err(format!(
|
2026-05-12 19:58:43 +01:00
|
|
|
"'{epic_id}' is not an epic (item_type: {:?})",
|
|
|
|
|
epic_view.item_type()
|
2026-04-29 21:41:44 +00:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 07:54:50 +00:00
|
|
|
// Parse the epic_id argument to a numeric EpicId for comparison.
|
|
|
|
|
let epic_numeric = crate::crdt_state::EpicId::from_crdt_str(epic_id);
|
|
|
|
|
|
2026-04-29 21:41:44 +00:00
|
|
|
// Find member items.
|
|
|
|
|
let all_items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
let mut member_items: Vec<Value> = Vec::new();
|
2026-05-13 05:02:52 +00:00
|
|
|
let mut done = 0usize;
|
2026-04-29 21:41:44 +00:00
|
|
|
for item in &all_items {
|
|
|
|
|
let sid = &item.story_id.0;
|
2026-05-12 19:58:43 +01:00
|
|
|
let Some(member_view) = crate::crdt_state::read_item(sid) else {
|
|
|
|
|
continue;
|
2026-04-29 21:41:44 +00:00
|
|
|
};
|
2026-05-13 07:54:50 +00:00
|
|
|
if member_view.epic() == epic_numeric {
|
2026-05-13 06:05:01 +00:00
|
|
|
// Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class
|
|
|
|
|
// Stage variants — no more orthogonal boolean flags.
|
2026-05-13 12:21:49 +00:00
|
|
|
let stage_name = item.stage.dir_name();
|
2026-05-13 05:02:52 +00:00
|
|
|
if matches!(item.stage, Stage::Done { .. }) {
|
|
|
|
|
done += 1;
|
|
|
|
|
}
|
2026-04-29 21:41:44 +00:00
|
|
|
member_items.push(json!({
|
|
|
|
|
"story_id": sid,
|
|
|
|
|
"name": item.name,
|
|
|
|
|
"stage": stage_name,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let total = member_items.len();
|
|
|
|
|
|
|
|
|
|
serde_json::to_string_pretty(&json!({
|
|
|
|
|
"epic_id": epic_id,
|
2026-05-12 19:58:43 +01:00
|
|
|
"name": epic_view.name(),
|
2026-04-29 21:41:44 +00:00
|
|
|
"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<Value> = 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() {
|
2026-05-12 19:58:43 +01:00
|
|
|
crate::crdt_state::init_for_test();
|
2026-04-29 21:41:44 +00:00
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
|
2026-05-13 07:54:50 +00:00
|
|
|
use crate::crdt_state::EpicId;
|
|
|
|
|
use crate::io::story_metadata::ItemType;
|
|
|
|
|
|
2026-05-12 19:58:43 +01:00
|
|
|
// Write a fake epic with the typed CRDT registers (story 933).
|
2026-05-13 07:54:50 +00:00
|
|
|
// Epics use numeric-only story_ids (see create_epic_file).
|
2026-04-29 21:41:44 +00:00
|
|
|
crate::db::write_item_with_content(
|
2026-05-13 07:54:50 +00:00
|
|
|
"9990",
|
2026-04-29 21:41:44 +00:00
|
|
|
"1_backlog",
|
2026-05-12 19:58:43 +01:00
|
|
|
"# Rollup Epic\n\n## Goal\n\nTest\n",
|
|
|
|
|
crate::db::ItemMeta::named("Rollup Epic"),
|
2026-04-29 21:41:44 +00:00
|
|
|
);
|
2026-05-13 07:54:50 +00:00
|
|
|
crate::crdt_state::set_item_type("9990", Some(ItemType::Epic));
|
2026-05-12 19:58:43 +01:00
|
|
|
|
2026-04-29 21:41:44 +00:00
|
|
|
// Write two member items: one done, one current.
|
|
|
|
|
crate::db::write_item_with_content(
|
|
|
|
|
"9991_story_member_done",
|
|
|
|
|
"5_done",
|
2026-05-12 19:58:43 +01:00
|
|
|
"# Done Member\n",
|
|
|
|
|
crate::db::ItemMeta::named("Done Member"),
|
2026-04-29 21:41:44 +00:00
|
|
|
);
|
2026-05-13 07:54:50 +00:00
|
|
|
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)));
|
2026-05-12 19:58:43 +01:00
|
|
|
|
2026-04-29 21:41:44 +00:00
|
|
|
crate::db::write_item_with_content(
|
|
|
|
|
"9992_story_member_current",
|
|
|
|
|
"2_current",
|
2026-05-12 19:58:43 +01:00
|
|
|
"# Current Member\n",
|
|
|
|
|
crate::db::ItemMeta::named("Current Member"),
|
2026-04-29 21:41:44 +00:00
|
|
|
);
|
2026-05-13 07:54:50 +00:00
|
|
|
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)));
|
2026-04-29 21:41:44 +00:00
|
|
|
|
|
|
|
|
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<Value> = serde_json::from_str(&result).unwrap();
|
|
|
|
|
let epic = parsed
|
|
|
|
|
.iter()
|
2026-05-13 07:54:50 +00:00
|
|
|
.find(|e| e["epic_id"] == "9990")
|
2026-04-29 21:41:44 +00:00
|
|
|
.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");
|
|
|
|
|
}
|
|
|
|
|
}
|