huskies: merge 730_story_use_numeric_only_story_ids_across_mcp_worktrees_git_branches_and_log_paths

This commit is contained in:
dave
2026-04-27 20:17:03 +00:00
parent 63d5a500de
commit 1388658ae8
10 changed files with 223 additions and 64 deletions
+2 -2
View File
@@ -257,8 +257,8 @@ mod tests {
.unwrap();
assert!(
result.contains("_bug_login_crash"),
"result should contain bug ID: {result}"
result.starts_with("Created bug: "),
"result should be a 'Created bug: <id>' message: {result}"
);
// Extract the actual bug ID from the result message (format: "Created bug: <id>").
let bug_id = result.trim_start_matches("Created bug: ").trim();
+6 -6
View File
@@ -111,14 +111,14 @@ mod tests {
.unwrap();
assert!(
result.contains("_spike_compare_encoders"),
"result should contain spike ID: {result}"
result.starts_with("Created spike: "),
"result should be a 'Created spike: <id>' message: {result}"
);
// Extract the actual spike ID from the result message (format: "Created spike: <id>").
let spike_id = result.trim_start_matches("Created spike: ").trim();
// Spike content should exist in the CRDT content store.
let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT");
assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---"));
assert!(contents.starts_with("---\ntype: spike\nname: \"Compare Encoders\"\n---"));
assert!(contents.contains("Which encoder is fastest?"));
}
@@ -133,15 +133,15 @@ mod tests {
)
.unwrap();
assert!(
result.contains("_spike_my_spike"),
"result should contain spike ID: {result}"
result.starts_with("Created spike: "),
"result should be a 'Created spike: <id>' message: {result}"
);
// Extract the actual spike ID from the result message (format: "Created spike: <id>").
let spike_id = result.trim_start_matches("Created spike: ").trim();
// Spike content should exist in the CRDT content store.
let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT");
assert!(contents.starts_with("---\nname: \"My Spike\"\n---"));
assert!(contents.starts_with("---\ntype: spike\nname: \"My Spike\"\n---"));
assert!(contents.contains("## Question\n\n- TBD\n"));
}
+4 -2
View File
@@ -300,12 +300,14 @@ pub(crate) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result<Strin
let root = ctx.state.get_project_root()?;
// Extract the numeric prefix (e.g. "42" from "42_story_foo")
// Extract the numeric prefix (e.g. "42" from "42" or from legacy "42_story_foo").
let story_number = story_id
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| format!("Invalid story_id format: '{story_id}'. Expected a numeric prefix (e.g. '42_story_foo')."))?;
.ok_or_else(|| {
format!("Invalid story_id format: '{story_id}'. Expected a numeric ID (e.g. '42').")
})?;
Ok(crate::chat::commands::unblock::unblock_by_number(
&root,
+56 -41
View File
@@ -7,7 +7,7 @@ use super::{next_item_number, slugify_name, write_story_content};
/// Create a bug file and store it in the database.
///
/// Also writes to the filesystem for backwards compatibility during migration.
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
/// Returns the bug_id (e.g. `"4"`).
#[allow(clippy::too_many_arguments)]
pub fn create_bug_file(
root: &Path,
@@ -26,10 +26,11 @@ pub fn create_bug_file(
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let bug_id = format!("{bug_number}_bug_{slug}");
let bug_id = format!("{bug_number}");
let mut content = String::new();
content.push_str("---\n");
content.push_str("type: bug\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();
@@ -66,7 +67,7 @@ pub fn create_bug_file(
/// Create a spike file and store it in the database.
///
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
/// Returns the spike_id (e.g. `"4"`).
pub fn create_spike_file(
root: &Path,
name: &str,
@@ -80,10 +81,11 @@ pub fn create_spike_file(
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let spike_id = format!("{spike_number}_spike_{slug}");
let spike_id = format!("{spike_number}");
let mut content = String::new();
content.push_str("---\n");
content.push_str("type: spike\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
content.push_str("---\n\n");
content.push_str(&format!("# Spike {spike_number}: {name}\n\n"));
@@ -122,7 +124,7 @@ pub fn create_spike_file(
/// Create a refactor work item and store it in the database.
///
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
/// Returns the refactor_id (e.g. `"5"`).
pub fn create_refactor_file(
root: &Path,
name: &str,
@@ -137,10 +139,11 @@ pub fn create_refactor_file(
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let refactor_id = format!("{refactor_number}_refactor_{slug}");
let refactor_id = format!("{refactor_number}");
let mut content = String::new();
content.push_str("---\n");
content.push_str("type: refactor\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();
@@ -176,10 +179,24 @@ pub fn create_refactor_file(
Ok(refactor_id)
}
/// Returns true if the item stem (filename without extension) is a bug item.
/// Returns true if the item stem is a bug item.
///
/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then
/// falls back to reading `type: bug` from the content store for numeric-only IDs.
fn is_bug_item(stem: &str) -> bool {
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
after_num.starts_with("_bug_")
if after_num.starts_with("_bug_") {
return true;
}
// Numeric-only ID: check content store front matter.
if after_num.is_empty() {
return crate::db::read_content(stem)
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.item_type)
.map(|t| t == "bug")
.unwrap_or(false);
}
false
}
/// Extract bug name from content (heading or front matter).
@@ -229,9 +246,23 @@ pub fn list_bug_files(_root: &Path) -> Result<Vec<(String, String)>, String> {
}
/// Returns true if the item stem is a refactor item.
///
/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), then
/// falls back to reading `type: refactor` from the content store for numeric-only IDs.
fn is_refactor_item(stem: &str) -> bool {
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
after_num.starts_with("_refactor_")
if after_num.starts_with("_refactor_") {
return true;
}
// Numeric-only ID: check content store front matter.
if after_num.is_empty() {
return crate::db::read_content(stem)
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.item_type)
.map(|t| t == "refactor")
.unwrap_or(false);
}
false
}
/// List all open refactors from CRDT + content store.
@@ -431,8 +462,8 @@ mod tests {
.unwrap();
assert!(
bug_id.ends_with("_bug_login_crash"),
"expected ID to end with _bug_login_crash, got: {bug_id}"
bug_id.chars().all(|c| c.is_ascii_digit()),
"bug ID must be numeric-only, got: {bug_id}"
);
// Check content exists (either in DB or filesystem).
@@ -446,8 +477,8 @@ mod tests {
.expect("bug content should exist");
assert!(
contents.starts_with("---\nname: \"Login Crash\"\n---"),
"bug file must start with YAML front matter"
contents.starts_with("---\ntype: bug\nname: \"Login Crash\"\n---"),
"bug file must start with YAML front matter including type field"
);
assert!(
contents.contains("Login Crash"),
@@ -499,16 +530,11 @@ mod tests {
)
.unwrap();
let contents = crate::db::read_content(&bug_id)
.or_else(|| {
let filepath = tmp.path().join(".huskies/work/1_backlog/1_bug_some_bug.md");
fs::read_to_string(filepath).ok()
})
.expect("bug content should exist");
let contents = crate::db::read_content(&bug_id).expect("bug content should exist");
assert!(
contents.starts_with("---\nname: \"Some Bug\"\n---"),
"bug file must have YAML front matter"
contents.starts_with("---\ntype: bug\nname: \"Some Bug\"\n---"),
"bug file must have YAML front matter with type field"
);
assert!(contents.contains("- [ ] Bug is fixed and verified"));
}
@@ -523,22 +549,16 @@ mod tests {
create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None, &[]).unwrap();
assert!(
spike_id.ends_with("_spike_filesystem_watcher_architecture"),
"expected ID to end with _spike_filesystem_watcher_architecture, got: {spike_id}"
spike_id.chars().all(|c| c.is_ascii_digit()),
"spike ID must be numeric-only, got: {spike_id}"
);
let contents = crate::db::read_content(&spike_id)
.or_else(|| {
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{spike_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("spike content should exist");
let contents = crate::db::read_content(&spike_id).expect("spike content should exist");
assert!(
contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"),
"spike file must start with YAML front matter"
contents
.starts_with("---\ntype: spike\nname: \"Filesystem Watcher Architecture\"\n---"),
"spike file must start with YAML front matter including type field"
);
assert!(
contents.contains("Filesystem Watcher Architecture"),
@@ -627,15 +647,10 @@ mod tests {
let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap();
assert!(
spike_id.ends_with("_spike_my_spike"),
"expected ID to end with _spike_my_spike, got: {spike_id}"
spike_id.chars().all(|c| c.is_ascii_digit()),
"spike ID must be numeric-only, got: {spike_id}"
);
let num: u32 = spike_id
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap();
let num: u32 = spike_id.parse().unwrap();
assert!(
num >= 7051,
"expected spike number >= 7051, got: {spike_id}"
+38 -2
View File
@@ -22,10 +22,11 @@ pub fn create_story_file(
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let story_id = format!("{story_number}_story_{slug}");
let story_id = format!("{story_number}");
let mut content = String::new();
content.push_str("---\n");
content.push_str("type: story\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();
@@ -167,7 +168,7 @@ mod tests {
fs::write(&filepath, &content).unwrap();
let written = fs::read_to_string(&filepath).unwrap();
assert!(written.starts_with("---\nname: \"My New Feature\"\n---"));
assert!(written.starts_with("---\n"));
assert!(written.contains(&format!("# Story {number}: My New Feature")));
assert!(written.contains("- [ ] It works"));
assert!(written.contains("- [ ] It is tested"));
@@ -230,5 +231,40 @@ mod tests {
assert_eq!(meta.depends_on, Some(vec![489]));
}
// ── Story 730: numeric-only story IDs ─────────────────────────────────────
#[test]
fn create_story_file_returns_numeric_only_id() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let result = create_story_file(tmp.path(), "My Feature", None, None, None, None, false);
assert!(
result.is_ok(),
"create_story_file should succeed: {result:?}"
);
let story_id = result.unwrap();
assert!(
story_id.chars().all(|c| c.is_ascii_digit()),
"story ID must be numeric-only, got: '{story_id}'"
);
}
#[test]
fn create_story_file_writes_type_field_in_front_matter() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let story_id =
create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false)
.unwrap();
let content = crate::db::read_content(&story_id).expect("content must exist");
let meta = crate::io::story_metadata::parse_front_matter(&content)
.expect("front matter should be valid");
assert_eq!(
meta.item_type.as_deref(),
Some("story"),
"front matter must contain type: story"
);
}
// ── Story 504: native JSON types in front_matter ───────────────────────────
}