storkit: merge 377_bug_update_story_mcp_tool_writes_front_matter_values_as_yaml_strings_instead_of_native_types
This commit is contained in:
@@ -102,13 +102,29 @@ fn run_command_with_timeout(
|
|||||||
args: &[&str],
|
args: &[&str],
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
) -> Result<(bool, String), String> {
|
) -> Result<(bool, String), String> {
|
||||||
let mut child = Command::new(program)
|
// On Linux, execve can return ETXTBSY (26) briefly after a file is written
|
||||||
.args(args)
|
// before the kernel releases its "write open" state. Retry once after a
|
||||||
|
// short pause to handle this race condition.
|
||||||
|
let mut last_err = None;
|
||||||
|
let mut cmd = Command::new(&program);
|
||||||
|
cmd.args(args)
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped());
|
||||||
.spawn()
|
let mut child = loop {
|
||||||
.map_err(|e| format!("Failed to spawn command: {e}"))?;
|
match cmd.spawn() {
|
||||||
|
Ok(c) => break c,
|
||||||
|
Err(e) if e.raw_os_error() == Some(26) => {
|
||||||
|
// ETXTBSY — wait briefly and retry once
|
||||||
|
if last_err.is_some() {
|
||||||
|
return Err(format!("Failed to spawn command: {e}"));
|
||||||
|
}
|
||||||
|
last_err = Some(e);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Failed to spawn command: {e}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Drain stdout/stderr in background threads so the pipe buffers never fill.
|
// Drain stdout/stderr in background threads so the pipe buffers never fill.
|
||||||
let stdout_handle = child.stdout.take().map(|r| {
|
let stdout_handle = child.stdout.take().map(|r| {
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ pub fn add_criterion_to_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode a string value as a YAML scalar.
|
||||||
|
///
|
||||||
|
/// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted).
|
||||||
|
/// Everything else is written as a quoted string to avoid ambiguity.
|
||||||
|
fn yaml_encode_scalar(value: &str) -> String {
|
||||||
|
match value {
|
||||||
|
"true" | "false" => value.to_string(),
|
||||||
|
s if s.parse::<i64>().is_ok() => s.to_string(),
|
||||||
|
s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the user story text and/or description in a story file.
|
/// Update the user story text and/or description in a story file.
|
||||||
///
|
///
|
||||||
/// At least one of `user_story` or `description` must be provided.
|
/// At least one of `user_story` or `description` must be provided.
|
||||||
@@ -209,7 +221,7 @@ pub fn update_story_in_file(
|
|||||||
|
|
||||||
if let Some(fields) = front_matter {
|
if let Some(fields) = front_matter {
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', ""));
|
let yaml_value = yaml_encode_scalar(value);
|
||||||
contents = set_front_matter_field(&contents, key, &yaml_value);
|
contents = set_front_matter_field(&contents, key, &yaml_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,4 +601,55 @@ mod tests {
|
|||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(contents.contains("agent: \"dev\""));
|
assert!(contents.contains("agent: \"dev\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_bool_front_matter_written_unquoted() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".storkit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("27_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("blocked".to_string(), "false".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(result.contains("blocked: false"), "bool should be unquoted: {result}");
|
||||||
|
assert!(!result.contains("blocked: \"false\""), "bool must not be quoted: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_integer_front_matter_written_unquoted() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".storkit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("28_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("retry_count".to_string(), "0".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(result.contains("retry_count: 0"), "integer should be unquoted: {result}");
|
||||||
|
assert!(!result.contains("retry_count: \"0\""), "integer must not be quoted: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_bool_front_matter_parseable_after_write() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".storkit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("29_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: My Story\n---\n\nNo sections.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("blocked".to_string(), "false".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "29_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
|
let meta = parse_front_matter(&contents).expect("front matter should parse");
|
||||||
|
assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user