From f72666b39ec596d44f870214ee773edd476d2ec6 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 17:33:30 +0000 Subject: [PATCH] story-kit: merge 267_story_mcp_update_story_tool_should_support_front_matter_fields --- server/src/http/mcp.rs | 32 +++++++++++++- server/src/http/workflow.rs | 78 ++++++++++++++++++++++++++++++--- server/src/io/story_metadata.rs | 2 +- 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 731c7fc..2b8b0b2 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -19,6 +19,7 @@ use poem::web::Data; use poem::{Body, Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::fs; use std::sync::Arc; @@ -638,7 +639,7 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, { "name": "update_story", - "description": "Update the user story text and/or description of an existing story file. Replaces the content of the '## User Story' and/or '## Description' section in place. Auto-commits via the filesystem watcher.", + "description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, manual_qa). Auto-commits via the filesystem watcher.", "inputSchema": { "type": "object", "properties": { @@ -653,6 +654,17 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { "description": { "type": "string", "description": "New description text to replace the '## Description' section content" + }, + "agent": { + "type": "string", + "description": "Set or change the 'agent' YAML front matter field" + }, + "front_matter": { + "type": "object", + "description": "Arbitrary YAML front matter key-value pairs to set or update", + "additionalProperties": { + "type": "string" + } } }, "required": ["story_id"] @@ -1544,8 +1556,24 @@ fn tool_update_story(args: &Value, ctx: &AppContext) -> Result { let user_story = args.get("user_story").and_then(|v| v.as_str()); let description = args.get("description").and_then(|v| v.as_str()); + // Collect front matter fields: explicit `agent` param + arbitrary `front_matter` object. + let mut front_matter: HashMap = HashMap::new(); + if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) { + front_matter.insert("agent".to_string(), agent.to_string()); + } + if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) { + for (k, v) in obj { + let val = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + front_matter.insert(k.clone(), val); + } + } + let front_matter_opt = if front_matter.is_empty() { None } else { Some(&front_matter) }; + let root = ctx.state.get_project_root()?; - update_story_in_file(&root, story_id, user_story, description)?; + update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?; Ok(format!("Updated story '{story_id}'.")) } diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 7c3390a..0137352 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -1,6 +1,6 @@ use crate::agents::AgentStatus; use crate::http::context::AppContext; -use crate::io::story_metadata::{parse_front_matter, write_coverage_baseline}; +use crate::io::story_metadata::{parse_front_matter, set_front_matter_field, write_coverage_baseline}; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; use serde::Serialize; use std::collections::HashMap; @@ -706,10 +706,13 @@ pub fn update_story_in_file( story_id: &str, user_story: Option<&str>, description: Option<&str>, + front_matter: Option<&HashMap>, ) -> Result<(), String> { - if user_story.is_none() && description.is_none() { + let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false); + if user_story.is_none() && description.is_none() && !has_front_matter_updates { return Err( - "At least one of 'user_story' or 'description' must be provided.".to_string(), + "At least one of 'user_story', 'description', or 'front_matter' must be provided." + .to_string(), ); } @@ -717,6 +720,13 @@ pub fn update_story_in_file( let mut contents = fs::read_to_string(&filepath) .map_err(|e| format!("Failed to read story file: {e}"))?; + if let Some(fields) = front_matter { + for (key, value) in fields { + let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', "")); + contents = set_front_matter_field(&contents, key, &yaml_value); + } + } + if let Some(us) = user_story { contents = replace_section_content(&contents, "User Story", us)?; } @@ -1597,7 +1607,7 @@ mod tests { let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n"; fs::write(&filepath, content).unwrap(); - update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None).unwrap(); + update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap(); let result = fs::read_to_string(&filepath).unwrap(); assert!(result.contains("New user story text"), "new text should be present"); @@ -1614,7 +1624,7 @@ mod tests { let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n"; fs::write(&filepath, content).unwrap(); - update_story_in_file(tmp.path(), "21_test", None, Some("New description")).unwrap(); + update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap(); let result = fs::read_to_string(&filepath).unwrap(); assert!(result.contains("New description"), "new description present"); @@ -1628,7 +1638,7 @@ mod tests { fs::create_dir_all(¤t).unwrap(); fs::write(current.join("22_test.md"), "---\nname: T\n---\n").unwrap(); - let result = update_story_in_file(tmp.path(), "22_test", None, None); + let result = update_story_in_file(tmp.path(), "22_test", None, None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("At least one")); } @@ -1644,11 +1654,65 @@ mod tests { ) .unwrap(); - let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None); + let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("User Story")); } + #[test] + fn update_story_sets_agent_front_matter_field() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("24_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("agent".to_string(), "dev".to_string()); + update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("agent: \"dev\""), "agent field should be set"); + assert!(result.contains("name: T"), "name field preserved"); + } + + #[test] + fn update_story_sets_arbitrary_front_matter_fields() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("25_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("manual_qa".to_string(), "true".to_string()); + fields.insert("priority".to_string(), "high".to_string()); + update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("manual_qa: \"true\""), "manual_qa field should be set"); + assert!(result.contains("priority: \"high\""), "priority field should be set"); + assert!(result.contains("name: T"), "name field preserved"); + } + + #[test] + fn update_story_front_matter_only_no_section_required() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + // File without a User Story section — front matter update should succeed + let filepath = current.join("26_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\nNo sections here.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("agent".to_string(), "dev".to_string()); + let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields)); + assert!(result.is_ok(), "front-matter-only update should not require body sections"); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("agent: \"dev\"")); + } + // ── Bug file helper tests ────────────────────────────────────────────────── #[test] diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 6d8c17e..af27573 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -161,7 +161,7 @@ fn remove_front_matter_field(contents: &str, key: &str) -> String { /// Insert or update a key: value pair in the YAML front matter of a markdown string. /// /// If no front matter (opening `---`) is found, returns the content unchanged. -fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String { +pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String { let mut lines: Vec = contents.lines().map(String::from).collect(); if lines.is_empty() || lines[0].trim() != "---" { return contents.to_string();