huskies: merge 493_bug_story_dependency_chain_not_firing_due_to_front_matter_format_issues
This commit is contained in:
@@ -339,6 +339,11 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional list of acceptance criteria"
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Optional list of story IDs this story depends on; written as a YAML inline sequence in front matter"
|
||||
},
|
||||
"commit": {
|
||||
"type": "boolean",
|
||||
"description": "If true, git-add and git-commit the new story file to the current branch"
|
||||
|
||||
@@ -23,6 +23,9 @@ pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String
|
||||
let acceptance_criteria: Option<Vec<String>> = args
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let depends_on: Option<Vec<u32>> = args
|
||||
.get("depends_on")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
// Spike 61: write the file only — the filesystem watcher detects the new
|
||||
// .md file in work/1_backlog/ and auto-commits with a deterministic message.
|
||||
let commit = false;
|
||||
@@ -33,6 +36,7 @@ pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String
|
||||
name,
|
||||
user_story,
|
||||
acceptance_criteria.as_deref(),
|
||||
depends_on.as_deref(),
|
||||
commit,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn create_story_file(
|
||||
name: &str,
|
||||
user_story: Option<&str>,
|
||||
acceptance_criteria: Option<&[String]>,
|
||||
depends_on: Option<&[u32]>,
|
||||
commit: bool,
|
||||
) -> Result<String, String> {
|
||||
let story_number = next_item_number(root)?;
|
||||
@@ -42,6 +43,10 @@ pub fn create_story_file(
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
||||
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) {
|
||||
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
|
||||
content.push_str(&format!("depends_on: [{}]\n", nums.join(", ")));
|
||||
}
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&format!("# Story {story_number}: {name}\n\n"));
|
||||
|
||||
@@ -185,12 +190,16 @@ pub fn add_criterion_to_file(
|
||||
|
||||
/// 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.
|
||||
/// Booleans (`true`/`false`), integers, and inline sequences (`[...]`) 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(),
|
||||
// YAML inline sequences like [490] or [490, 491] — write unquoted so
|
||||
// serde_yaml can deserialise them as Vec<u32>.
|
||||
s if s.starts_with('[') && s.ends_with(']') => s.to_string(),
|
||||
s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
|
||||
}
|
||||
}
|
||||
@@ -321,7 +330,7 @@ mod tests {
|
||||
fn create_story_with_colon_in_name_produces_valid_yaml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = "Server-owned agent completion: remove report_completion dependency";
|
||||
let result = create_story_file(tmp.path(), name, None, None, false);
|
||||
let result = create_story_file(tmp.path(), name, None, None, None, false);
|
||||
assert!(result.is_ok(), "create_story_file failed: {result:?}");
|
||||
|
||||
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
||||
@@ -652,4 +661,73 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
|
||||
// ── Bug 493 regression tests ──────────────────────────────────────────────
|
||||
|
||||
/// Bug 493 fix 1: update_story with depends_on as a string like "[490]" must
|
||||
/// write the value unquoted so serde_yaml can deserialise it as Vec<u32>.
|
||||
#[test]
|
||||
fn update_story_depends_on_stored_as_yaml_array_not_quoted_string() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("30_test.md");
|
||||
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
fields.insert("depends_on".to_string(), "[490]".to_string());
|
||||
update_story_in_file(tmp.path(), "30_test", None, None, Some(&fields)).unwrap();
|
||||
|
||||
let result = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(result.contains("depends_on: [490]"), "should be unquoted array: {result}");
|
||||
assert!(!result.contains("depends_on: \"[490]\""), "must not be quoted: {result}");
|
||||
|
||||
// Must round-trip through the parser correctly.
|
||||
let meta = parse_front_matter(&result).expect("front matter should parse");
|
||||
assert_eq!(meta.depends_on, Some(vec![490]));
|
||||
}
|
||||
|
||||
/// Bug 493 fix 2: create_story_file with depends_on must write it as a
|
||||
/// YAML front matter array, not as an acceptance criterion checkbox.
|
||||
#[test]
|
||||
fn create_story_with_depends_on_writes_front_matter_array() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_id = create_story_file(
|
||||
tmp.path(),
|
||||
"Dependent Story",
|
||||
None,
|
||||
None,
|
||||
Some(&[489]),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
||||
let contents = fs::read_to_string(backlog.join(format!("{story_id}.md"))).unwrap();
|
||||
|
||||
// depends_on must be in front matter, not in AC text.
|
||||
assert!(contents.contains("depends_on: [489]"), "missing front matter: {contents}");
|
||||
assert!(!contents.contains("- [ ] depends_on"), "must not appear as checkbox: {contents}");
|
||||
|
||||
let meta = parse_front_matter(&contents).expect("front matter should parse");
|
||||
assert_eq!(meta.depends_on, Some(vec![489]));
|
||||
}
|
||||
|
||||
/// Multi-element depends_on array round-trips correctly.
|
||||
#[test]
|
||||
fn update_story_depends_on_multi_element_array() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("31_test.md");
|
||||
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
fields.insert("depends_on".to_string(), "[490, 491]".to_string());
|
||||
update_story_in_file(tmp.path(), "31_test", None, None, Some(&fields)).unwrap();
|
||||
|
||||
let result = fs::read_to_string(&filepath).unwrap();
|
||||
let meta = parse_front_matter(&result).expect("front matter should parse");
|
||||
assert_eq!(meta.depends_on, Some(vec![490, 491]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user