Files
huskies/server/src/chat/commands/unblock.rs
T

292 lines
9.9 KiB
Rust
Raw Normal View History

//! Handler for the `unblock` command.
//!
//! `{bot_name} unblock {number}` finds the blocked work item by number across
//! all pipeline stages, clears the `blocked` flag, resets `retry_count` to 0,
//! and returns a confirmation.
use super::CommandContext;
2026-04-29 15:17:47 +00:00
use crate::io::story_metadata::{clear_front_matter_field_in_content, parse_front_matter};
use std::path::Path;
/// Handle the `unblock` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, checks that it is
/// blocked, clears the `blocked` flag, resets `retry_count` to 0, and returns
/// a Markdown confirmation string.
pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} unblock <number>` (e.g. `unblock 42`)",
ctx.services.bot_name
));
}
Some(unblock_by_number(ctx.effective_root(), num_str))
}
/// Core unblock logic: find story by numeric prefix and reset its blocked state.
///
/// Returns a Markdown-formatted response string suitable for all transports.
/// Also used by the MCP `unblock` tool.
///
/// Lookup priority: CRDT → content store.
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
unblock_by_story_id(&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");
}
2026-04-29 15:17:47 +00:00
// retry_count lives in the CRDT; clear any stale copy from front-matter.
updated = clear_front_matter_field_in_content(&updated, "retry_count");
crate::db::write_content(story_id, &updated);
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(story_id, &stage, &updated);
2026-04-29 15:17:47 +00:00
crate::crdt_state::set_retry_count(story_id, 0);
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(", ")
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::super::{CommandDispatch, try_handle_command};
fn unblock_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy unblock {args}"))
}
use crate::chat::test_helpers::write_story_file;
#[test]
fn unblock_command_is_registered() {
use super::super::commands;
assert!(
commands().iter().any(|c| c.name == "unblock"),
"unblock command must be in the registry"
);
}
#[test]
fn unblock_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap();
assert!(
output.contains("unblock"),
"help should list unblock command: {output}"
);
}
#[test]
fn unblock_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = unblock_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage hint: {output}"
);
}
#[test]
fn unblock_command_non_numeric_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = unblock_cmd_with_root(tmp.path(), "abc").unwrap();
assert!(
output.contains("Usage"),
"non-numeric arg should show usage hint: {output}"
);
}
#[test]
fn unblock_command_not_found_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = unblock_cmd_with_root(tmp.path(), "999").unwrap();
assert!(
output.contains("999") && output.contains("found"),
"not-found message should include number and 'found': {output}"
);
}
#[test]
fn unblock_command_not_blocked_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
write_story_file(
tmp.path(),
"2_current",
"42_story_test.md",
"---\nname: Test Story\nretry_count: 2\n---\n# Story\n",
);
let output = unblock_cmd_with_root(tmp.path(), "42").unwrap();
assert!(
output.contains("not blocked"),
"non-blocked story should return not-blocked error: {output}"
);
}
#[test]
fn unblock_command_clears_blocked_and_resets_retry_count() {
2026-04-29 15:17:47 +00:00
crate::crdt_state::init_for_test();
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",
"9903_story_stuck.md",
"---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n",
);
2026-04-29 15:17:47 +00:00
// Seed the story in the CRDT with retry_count=5 so set_retry_count can reset it.
crate::crdt_state::write_item(
"9903_story_stuck",
"2_current",
Some("Stuck Story"),
None,
Some(5),
Some(true),
None,
None,
None,
None,
);
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("9903_story_stuck"),
"should include story_id in response: {output}"
);
2026-04-29 15:17:47 +00:00
// The unblock command writes back via the content store; blocked field should be gone.
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}"
);
2026-04-29 15:17:47 +00:00
// retry_count is now in the CRDT, not in front-matter.
assert!(
2026-04-29 15:17:47 +00:00
!contents.contains("retry_count:"),
"retry_count should be cleared from front-matter after unblock: {contents}"
);
let item = crate::crdt_state::read_item("9903_story_stuck")
.expect("story should be in CRDT after unblock");
assert_eq!(
item.retry_count,
Some(0),
"retry_count should be reset to 0 in CRDT after unblock"
);
}
#[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",
"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(), "9901").unwrap();
assert!(
output.contains("Unblocked"),
"should unblock story in qa stage: {output}"
);
}
#[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",
"9902_story_blocked_one.md",
"---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n",
);
let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap();
assert!(
output.contains("9902_story_blocked_one"),
"response should include story_id: {output}"
);
}
}