2026-03-28 09:01:09 +00:00
|
|
|
//! 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;
|
|
|
|
|
use crate::io::story_metadata::{clear_front_matter_field, parse_front_matter, set_front_matter_field};
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
/// All pipeline stage directories to search when finding a work item by number.
|
|
|
|
|
const SEARCH_DIRS: &[&str] = &[
|
|
|
|
|
"1_backlog",
|
|
|
|
|
"2_current",
|
|
|
|
|
"3_qa",
|
|
|
|
|
"4_merge",
|
|
|
|
|
"5_done",
|
|
|
|
|
"6_archived",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// 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.bot_name
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(unblock_by_number(ctx.project_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.
|
|
|
|
|
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
|
|
|
|
|
// Find the story file across all pipeline stages by numeric prefix.
|
|
|
|
|
let mut found: Option<(std::path::PathBuf, String)> = None;
|
|
|
|
|
|
|
|
|
|
'outer: for stage_dir in SEARCH_DIRS {
|
|
|
|
|
let dir = project_root.join(".storkit").join("work").join(stage_dir);
|
|
|
|
|
if !dir.exists() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
|
|
|
|
let file_num = stem
|
|
|
|
|
.split('_')
|
|
|
|
|
.next()
|
|
|
|
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
if file_num == story_number {
|
|
|
|
|
found = Some((path.to_path_buf(), stem.to_string()));
|
|
|
|
|
break 'outer;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (path, story_id) = match found {
|
|
|
|
|
Some(f) => f,
|
|
|
|
|
None => {
|
|
|
|
|
return format!("No story, bug, or spike with number **{story_number}** found.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
unblock_by_path(&path, &story_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Core unblock logic: reset blocked state for a known story file path.
|
|
|
|
|
///
|
|
|
|
|
/// Reads front matter, verifies the story is blocked, clears the `blocked`
|
|
|
|
|
/// flag, and resets `retry_count` to 0. Also used by the MCP `unblock` tool
|
|
|
|
|
/// when the caller has already resolved the story path from a full `story_id`.
|
|
|
|
|
pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|
|
|
|
let contents = match std::fs::read_to_string(path) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return format!("Failed to read story file: {e}"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-03-28 15:33:01 +00:00
|
|
|
let has_blocked = meta.blocked == Some(true);
|
|
|
|
|
let has_merge_failure = meta.merge_failure.is_some();
|
|
|
|
|
|
|
|
|
|
if !has_blocked && !has_merge_failure {
|
2026-03-28 09:01:09 +00:00
|
|
|
return format!(
|
|
|
|
|
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 15:33:01 +00:00
|
|
|
// Clear the blocked flag if present.
|
|
|
|
|
if has_blocked {
|
|
|
|
|
if let Err(e) = clear_front_matter_field(path, "blocked") {
|
|
|
|
|
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear merge_failure if present.
|
|
|
|
|
if has_merge_failure {
|
|
|
|
|
if let Err(e) = clear_front_matter_field(path, "merge_failure") {
|
|
|
|
|
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
|
|
|
|
}
|
2026-03-28 09:01:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
|
|
|
|
let updated_contents = match std::fs::read_to_string(path) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return format!("Failed to re-read story file after unblocking: {e}"),
|
|
|
|
|
};
|
|
|
|
|
let with_retry_reset = set_front_matter_field(&updated_contents, "retry_count", "0");
|
|
|
|
|
if let Err(e) = std::fs::write(path, &with_retry_reset) {
|
|
|
|
|
return format!("Failed to reset retry_count on **{story_id}**: {e}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 15:33:01 +00:00
|
|
|
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(", "))
|
2026-03-28 09:01:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use crate::agents::AgentPool;
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
|
|
|
|
|
use super::super::{CommandDispatch, try_handle_command};
|
|
|
|
|
|
|
|
|
|
fn unblock_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
|
|
|
|
let agents = Arc::new(AgentPool::new_test(3000));
|
|
|
|
|
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
|
|
|
|
let room_id = "!test:example.com".to_string();
|
|
|
|
|
let dispatch = CommandDispatch {
|
|
|
|
|
bot_name: "Timmy",
|
|
|
|
|
bot_user_id: "@timmy:homeserver.local",
|
|
|
|
|
project_root: root,
|
|
|
|
|
agents: &agents,
|
|
|
|
|
ambient_rooms: &ambient_rooms,
|
|
|
|
|
room_id: &room_id,
|
|
|
|
|
};
|
|
|
|
|
try_handle_command(&dispatch, &format!("@timmy unblock {args}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
|
|
|
|
let dir = root.join(".storkit/work").join(stage);
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
std::fs::write(dir.join(filename), content).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"7_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();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Unblocked") && output.contains("Stuck Story"),
|
|
|
|
|
"should confirm unblock with story name: {output}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("7_story_stuck"),
|
|
|
|
|
"should include story_id in response: {output}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let contents = std::fs::read_to_string(
|
|
|
|
|
tmp.path().join(".storkit/work/2_current/7_story_stuck.md"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!contents.contains("blocked:"),
|
|
|
|
|
"blocked field should be removed: {contents}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
contents.contains("retry_count: 0"),
|
|
|
|
|
"retry_count should be reset to 0: {contents}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unblock_command_finds_story_in_any_stage() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"3_qa",
|
|
|
|
|
"10_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();
|
|
|
|
|
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();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"1_backlog",
|
|
|
|
|
"5_story_blocked_one.md",
|
|
|
|
|
"---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let output = unblock_cmd_with_root(tmp.path(), "5").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("5_story_blocked_one"),
|
|
|
|
|
"response should include story_id: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|