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" },
|
"items": { "type": "string" },
|
||||||
"description": "Optional list of acceptance criteria"
|
"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": {
|
"commit": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If true, git-add and git-commit the new story file to the current branch"
|
"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
|
let acceptance_criteria: Option<Vec<String>> = args
|
||||||
.get("acceptance_criteria")
|
.get("acceptance_criteria")
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
.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
|
// 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.
|
// .md file in work/1_backlog/ and auto-commits with a deterministic message.
|
||||||
let commit = false;
|
let commit = false;
|
||||||
@@ -33,6 +36,7 @@ pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String
|
|||||||
name,
|
name,
|
||||||
user_story,
|
user_story,
|
||||||
acceptance_criteria.as_deref(),
|
acceptance_criteria.as_deref(),
|
||||||
|
depends_on.as_deref(),
|
||||||
commit,
|
commit,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub fn create_story_file(
|
|||||||
name: &str,
|
name: &str,
|
||||||
user_story: Option<&str>,
|
user_story: Option<&str>,
|
||||||
acceptance_criteria: Option<&[String]>,
|
acceptance_criteria: Option<&[String]>,
|
||||||
|
depends_on: Option<&[u32]>,
|
||||||
commit: bool,
|
commit: bool,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let story_number = next_item_number(root)?;
|
let story_number = next_item_number(root)?;
|
||||||
@@ -42,6 +43,10 @@ pub fn create_story_file(
|
|||||||
let mut content = String::new();
|
let mut content = String::new();
|
||||||
content.push_str("---\n");
|
content.push_str("---\n");
|
||||||
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
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("---\n\n");
|
||||||
content.push_str(&format!("# Story {story_number}: {name}\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.
|
/// Encode a string value as a YAML scalar.
|
||||||
///
|
///
|
||||||
/// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted).
|
/// Booleans (`true`/`false`), integers, and inline sequences (`[...]`) are
|
||||||
/// Everything else is written as a quoted string to avoid ambiguity.
|
/// written as native YAML types (unquoted). Everything else is written as a
|
||||||
|
/// quoted string to avoid ambiguity.
|
||||||
fn yaml_encode_scalar(value: &str) -> String {
|
fn yaml_encode_scalar(value: &str) -> String {
|
||||||
match value {
|
match value {
|
||||||
"true" | "false" => value.to_string(),
|
"true" | "false" => value.to_string(),
|
||||||
s if s.parse::<i64>().is_ok() => s.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', "")),
|
s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +330,7 @@ mod tests {
|
|||||||
fn create_story_with_colon_in_name_produces_valid_yaml() {
|
fn create_story_with_colon_in_name_produces_valid_yaml() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let name = "Server-owned agent completion: remove report_completion dependency";
|
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:?}");
|
assert!(result.is_ok(), "create_story_file failed: {result:?}");
|
||||||
|
|
||||||
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
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");
|
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");
|
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