huskies: merge 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database

This commit is contained in:
dave
2026-04-08 03:03:59 +00:00
parent f43d30bdae
commit 8fd49d563e
27 changed files with 1663 additions and 1295 deletions
+85 -14
View File
@@ -5,7 +5,7 @@
//! and returns a confirmation.
use super::CommandContext;
use crate::io::story_metadata::{clear_front_matter_field, parse_front_matter, set_front_matter_field};
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
use std::path::Path;
/// All pipeline stage directories to search when finding a work item by number.
@@ -41,7 +41,24 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
/// Returns a Markdown-formatted response string suitable for all transports.
/// Also used by the MCP `unblock` tool.
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
// Find the story file across all pipeline stages by numeric prefix.
// Try content store / CRDT first to find story by numeric prefix.
let mut found_id: Option<String> = None;
// Check content store IDs.
for id in crate::db::all_content_ids() {
let num = id.split('_').next().unwrap_or("");
if num == story_number {
found_id = Some(id);
break;
}
}
// If found in content store, use DB-backed unblock.
if let Some(story_id) = found_id {
return unblock_by_story_id(&story_id);
}
// Fallback: find the story file across all pipeline stages on filesystem.
let mut found: Option<(std::path::PathBuf, String)> = None;
'outer: for stage_dir in SEARCH_DIRS {
@@ -80,6 +97,49 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
unblock_by_path(&path, &story_id)
}
/// Unblock a story using the content store (DB-backed).
fn unblock_by_story_id(story_id: &str) -> String {
let contents = match crate::db::read_content(story_id) {
Some(c) => c,
None => return format!("Failed to read story content for **{story_id}**"),
};
let meta = match parse_front_matter(&contents) {
Ok(m) => m,
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
};
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
let has_blocked = meta.blocked == Some(true);
let has_merge_failure = meta.merge_failure.is_some();
if !has_blocked && !has_merge_failure {
return format!(
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
);
}
let mut updated = contents;
if has_blocked {
updated = clear_front_matter_field_in_content(&updated, "blocked");
}
if has_merge_failure {
updated = clear_front_matter_field_in_content(&updated, "merge_failure");
}
updated = set_front_matter_field(&updated, "retry_count", "0");
crate::db::write_content(story_id, &updated);
let stage = crate::crdt_state::read_item(story_id)
.map(|i| i.stage)
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(story_id, &stage, &updated);
let mut cleared = Vec::new();
if has_blocked { cleared.push("blocked"); }
if has_merge_failure { cleared.push("merge_failure"); }
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
}
/// Core unblock logic: reset blocked state for a known story file path.
///
/// Reads front matter, verifies the story is blocked, clears the `blocked`
@@ -234,27 +294,34 @@ mod tests {
#[test]
fn unblock_command_clears_blocked_and_resets_retry_count() {
let tmp = tempfile::TempDir::new().unwrap();
// Use a high story number (9903) to avoid collisions with other tests in the
// global content store.
write_story_file(
tmp.path(),
"2_current",
"7_story_stuck.md",
"9903_story_stuck.md",
"---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n",
);
let output = unblock_cmd_with_root(tmp.path(), "7").unwrap();
let output = unblock_cmd_with_root(tmp.path(), "9903").unwrap();
assert!(
output.contains("Unblocked") && output.contains("Stuck Story"),
"should confirm unblock with story name: {output}"
);
assert!(
output.contains("7_story_stuck"),
output.contains("9903_story_stuck"),
"should include story_id in response: {output}"
);
let contents = std::fs::read_to_string(
tmp.path().join(".huskies/work/2_current/7_story_stuck.md"),
)
.unwrap();
// The unblock command writes back via the content store; read from there.
let contents = crate::db::read_content("9903_story_stuck")
.or_else(|| {
std::fs::read_to_string(
tmp.path().join(".huskies/work/2_current/9903_story_stuck.md"),
)
.ok()
})
.expect("story content should be readable after unblock");
assert!(
!contents.contains("blocked:"),
"blocked field should be removed: {contents}"
@@ -268,14 +335,16 @@ mod tests {
#[test]
fn unblock_command_finds_story_in_any_stage() {
let tmp = tempfile::TempDir::new().unwrap();
// Use a high story number (9901) to avoid collisions with other tests in the
// global content store.
write_story_file(
tmp.path(),
"3_qa",
"10_story_in_qa.md",
"9901_story_in_qa.md",
"---\nname: In QA\nblocked: true\nretry_count: 3\n---\n# Story\n",
);
let output = unblock_cmd_with_root(tmp.path(), "10").unwrap();
let output = unblock_cmd_with_root(tmp.path(), "9901").unwrap();
assert!(
output.contains("Unblocked"),
"should unblock story in qa stage: {output}"
@@ -285,16 +354,18 @@ mod tests {
#[test]
fn unblock_command_includes_story_id_in_response() {
let tmp = tempfile::TempDir::new().unwrap();
// Use a high story number (9902) to avoid collisions with other tests in the
// global content store.
write_story_file(
tmp.path(),
"1_backlog",
"5_story_blocked_one.md",
"9902_story_blocked_one.md",
"---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n",
);
let output = unblock_cmd_with_root(tmp.path(), "5").unwrap();
let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap();
assert!(
output.contains("5_story_blocked_one"),
output.contains("9902_story_blocked_one"),
"response should include story_id: {output}"
);
}