story-kit: merge 267_story_mcp_update_story_tool_should_support_front_matter_fields
This commit is contained in:
@@ -19,6 +19,7 @@ use poem::web::Data;
|
|||||||
use poem::{Body, Request, Response};
|
use poem::{Body, Request, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -638,7 +639,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_story",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -653,6 +654,17 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New description text to replace the '## Description' section content"
|
"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"]
|
"required": ["story_id"]
|
||||||
@@ -1544,8 +1556,24 @@ fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|||||||
let user_story = args.get("user_story").and_then(|v| v.as_str());
|
let user_story = args.get("user_story").and_then(|v| v.as_str());
|
||||||
let description = args.get("description").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<String, String> = 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()?;
|
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}'."))
|
Ok(format!("Updated story '{story_id}'."))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::agents::AgentStatus;
|
use crate::agents::AgentStatus;
|
||||||
use crate::http::context::AppContext;
|
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 crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -706,10 +706,13 @@ pub fn update_story_in_file(
|
|||||||
story_id: &str,
|
story_id: &str,
|
||||||
user_story: Option<&str>,
|
user_story: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
front_matter: Option<&HashMap<String, String>>,
|
||||||
) -> Result<(), String> {
|
) -> 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(
|
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)
|
let mut contents = fs::read_to_string(&filepath)
|
||||||
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
.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 {
|
if let Some(us) = user_story {
|
||||||
contents = replace_section_content(&contents, "User Story", us)?;
|
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";
|
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();
|
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();
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(result.contains("New user story text"), "new text should be present");
|
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";
|
let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
||||||
fs::write(&filepath, content).unwrap();
|
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();
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(result.contains("New description"), "new description present");
|
assert!(result.contains("New description"), "new description present");
|
||||||
@@ -1628,7 +1638,7 @@ mod tests {
|
|||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::write(current.join("22_test.md"), "---\nname: T\n---\n").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.is_err());
|
||||||
assert!(result.unwrap_err().contains("At least one"));
|
assert!(result.unwrap_err().contains("At least one"));
|
||||||
}
|
}
|
||||||
@@ -1644,11 +1654,65 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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.is_err());
|
||||||
assert!(result.unwrap_err().contains("User Story"));
|
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 ──────────────────────────────────────────────────
|
// ── Bug file helper tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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.
|
/// 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.
|
/// 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<String> = contents.lines().map(String::from).collect();
|
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
||||||
if lines.is_empty() || lines[0].trim() != "---" {
|
if lines.is_empty() || lines[0].trim() != "---" {
|
||||||
return contents.to_string();
|
return contents.to_string();
|
||||||
|
|||||||
Reference in New Issue
Block a user