2026-04-15 17:57:56 +00:00
|
|
|
//! Handler for the `freeze` and `unfreeze` commands.
|
|
|
|
|
//!
|
2026-04-29 22:12:23 +00:00
|
|
|
//! `freeze <number>` transitions the story to `Stage::Frozen`, halting pipeline
|
|
|
|
|
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
|
2026-04-15 17:57:56 +00:00
|
|
|
|
|
|
|
|
use super::CommandContext;
|
2026-05-08 14:24:20 +00:00
|
|
|
use crate::db::yaml_legacy::parse_front_matter;
|
2026-04-15 17:57:56 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
/// Handle the `freeze` command.
|
|
|
|
|
///
|
|
|
|
|
/// Parses `<number>` from `ctx.args`, locates the work item, and sets
|
|
|
|
|
/// `frozen: true` in its front matter.
|
|
|
|
|
pub(super) fn handle_freeze(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: `{} freeze <number>` (e.g. `freeze 42`)",
|
2026-04-25 20:37:10 +00:00
|
|
|
ctx.services.bot_name
|
2026-04-15 17:57:56 +00:00
|
|
|
));
|
|
|
|
|
}
|
2026-04-25 20:37:10 +00:00
|
|
|
Some(freeze_by_number(ctx.effective_root(), num_str))
|
2026-04-15 17:57:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Core freeze logic: find story by numeric prefix and set `frozen: true`.
|
|
|
|
|
///
|
|
|
|
|
/// Returns a Markdown-formatted response string suitable for all transports.
|
|
|
|
|
pub(crate) fn freeze_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.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
freeze_by_story_id(&story_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn freeze_by_story_id(story_id: &str) -> String {
|
2026-04-29 23:48:30 +00:00
|
|
|
let story_name = resolve_story_name(story_id);
|
2026-04-15 17:57:56 +00:00
|
|
|
|
2026-04-29 23:48:30 +00:00
|
|
|
match crate::service::work_item::freeze::freeze(story_id) {
|
|
|
|
|
Ok(crate::service::work_item::FreezeStatus::AlreadyFrozen) => {
|
|
|
|
|
format!("**{story_name}** ({story_id}) is already frozen.")
|
|
|
|
|
}
|
|
|
|
|
Ok(crate::service::work_item::FreezeStatus::Frozen) => format!(
|
2026-04-29 22:12:23 +00:00
|
|
|
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
|
|
|
|
|
),
|
|
|
|
|
Err(e) => format!("Failed to freeze **{story_name}** ({story_id}): {e}"),
|
|
|
|
|
}
|
2026-04-15 17:57:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle the `unfreeze` command.
|
|
|
|
|
///
|
|
|
|
|
/// Parses `<number>` from `ctx.args`, locates the work item, and clears the
|
|
|
|
|
/// `frozen` flag to resume normal pipeline behaviour.
|
|
|
|
|
pub(super) fn handle_unfreeze(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: `{} unfreeze <number>` (e.g. `unfreeze 42`)",
|
2026-04-25 20:37:10 +00:00
|
|
|
ctx.services.bot_name
|
2026-04-15 17:57:56 +00:00
|
|
|
));
|
|
|
|
|
}
|
2026-04-25 20:37:10 +00:00
|
|
|
Some(unfreeze_by_number(ctx.effective_root(), num_str))
|
2026-04-15 17:57:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag.
|
|
|
|
|
pub(crate) fn unfreeze_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.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
unfreeze_by_story_id(&story_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unfreeze_by_story_id(story_id: &str) -> String {
|
2026-04-29 23:48:30 +00:00
|
|
|
let story_name = resolve_story_name(story_id);
|
2026-04-15 17:57:56 +00:00
|
|
|
|
2026-04-29 23:48:30 +00:00
|
|
|
match crate::service::work_item::freeze::unfreeze(story_id) {
|
|
|
|
|
Ok(crate::service::work_item::UnfreezeStatus::NotFrozen) => {
|
|
|
|
|
format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.")
|
|
|
|
|
}
|
|
|
|
|
Ok(crate::service::work_item::UnfreezeStatus::Unfrozen) => {
|
2026-04-29 22:12:23 +00:00
|
|
|
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
|
|
|
|
|
}
|
|
|
|
|
Err(e) => format!("Failed to unfreeze **{story_name}** ({story_id}): {e}"),
|
|
|
|
|
}
|
2026-04-15 17:57:56 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 23:48:30 +00:00
|
|
|
/// Look up the display name for a story by reading its content store entry.
|
|
|
|
|
///
|
|
|
|
|
/// Falls back to `story_id` if the content is missing or the front matter
|
|
|
|
|
/// cannot be parsed.
|
|
|
|
|
fn resolve_story_name(story_id: &str) -> String {
|
|
|
|
|
crate::db::read_content(story_id)
|
|
|
|
|
.as_deref()
|
|
|
|
|
.and_then(|c| parse_front_matter(c).ok())
|
|
|
|
|
.and_then(|m| m.name)
|
|
|
|
|
.unwrap_or_else(|| story_id.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 17:57:56 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use crate::chat::test_helpers::write_story_file;
|
|
|
|
|
|
|
|
|
|
use super::super::{CommandDispatch, try_handle_command};
|
|
|
|
|
|
|
|
|
|
fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
2026-04-25 20:37:10 +00:00
|
|
|
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
|
2026-04-15 17:57:56 +00:00
|
|
|
let room_id = "!test:example.com".to_string();
|
|
|
|
|
let dispatch = CommandDispatch {
|
2026-04-25 20:37:10 +00:00
|
|
|
services: &services,
|
|
|
|
|
project_root: &services.project_root,
|
2026-04-15 17:57:56 +00:00
|
|
|
bot_user_id: "@timmy:homeserver.local",
|
|
|
|
|
room_id: &room_id,
|
|
|
|
|
};
|
|
|
|
|
try_handle_command(&dispatch, &format!("@timmy freeze {args}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
2026-04-25 20:37:10 +00:00
|
|
|
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
|
2026-04-15 17:57:56 +00:00
|
|
|
let room_id = "!test:example.com".to_string();
|
|
|
|
|
let dispatch = CommandDispatch {
|
2026-04-25 20:37:10 +00:00
|
|
|
services: &services,
|
|
|
|
|
project_root: &services.project_root,
|
2026-04-15 17:57:56 +00:00
|
|
|
bot_user_id: "@timmy:homeserver.local",
|
|
|
|
|
room_id: &room_id,
|
|
|
|
|
};
|
|
|
|
|
try_handle_command(&dispatch, &format!("@timmy unfreeze {args}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn freeze_command_is_registered() {
|
|
|
|
|
use super::super::commands;
|
|
|
|
|
assert!(
|
|
|
|
|
commands().iter().any(|c| c.name == "freeze"),
|
|
|
|
|
"freeze command must be in the registry"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unfreeze_command_is_registered() {
|
|
|
|
|
use super::super::commands;
|
|
|
|
|
assert!(
|
|
|
|
|
commands().iter().any(|c| c.name == "unfreeze"),
|
|
|
|
|
"unfreeze command must be in the registry"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn freeze_command_no_args_returns_usage() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = freeze_cmd_with_root(tmp.path(), "").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Usage"),
|
|
|
|
|
"no args should show usage: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unfreeze_command_no_args_returns_usage() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Usage"),
|
|
|
|
|
"no args should show usage: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn freeze_command_not_found_returns_error() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let output = freeze_cmd_with_root(tmp.path(), "9988").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("9988") && output.contains("found"),
|
|
|
|
|
"not-found message should include number and 'found': {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-29 22:12:23 +00:00
|
|
|
fn freeze_command_sets_stage_to_frozen() {
|
2026-04-15 17:57:56 +00:00
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
2026-04-29 22:12:23 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
2026-04-15 17:57:56 +00:00
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"9940_story_freezeme.md",
|
|
|
|
|
"---\nname: Freeze Me\n---\n# Story\n",
|
|
|
|
|
);
|
|
|
|
|
let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Frozen") && output.contains("Freeze Me"),
|
|
|
|
|
"should confirm freeze with story name: {output}"
|
|
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
let item = crate::pipeline_state::read_typed("9940_story_freezeme")
|
|
|
|
|
.expect("read_typed should succeed")
|
|
|
|
|
.expect("item should be present");
|
2026-04-15 17:57:56 +00:00
|
|
|
assert!(
|
2026-04-29 22:12:23 +00:00
|
|
|
item.stage.is_frozen(),
|
|
|
|
|
"stage should be Frozen after freeze: {:?}",
|
|
|
|
|
item.stage
|
2026-04-15 17:57:56 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-29 22:12:23 +00:00
|
|
|
fn unfreeze_command_restores_prior_stage() {
|
2026-04-15 17:57:56 +00:00
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
2026-04-29 22:12:23 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
2026-04-15 17:57:56 +00:00
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"9941_story_frozen.md",
|
2026-04-29 22:12:23 +00:00
|
|
|
"---\nname: Frozen Story\n---\n# Story\n",
|
2026-04-15 17:57:56 +00:00
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
// Freeze first.
|
|
|
|
|
let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
freeze_out.contains("Frozen"),
|
|
|
|
|
"should confirm freeze: {freeze_out}"
|
|
|
|
|
);
|
|
|
|
|
// Now unfreeze.
|
2026-04-15 17:57:56 +00:00
|
|
|
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("Unfrozen") && output.contains("Frozen Story"),
|
|
|
|
|
"should confirm unfreeze with story name: {output}"
|
|
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
let item = crate::pipeline_state::read_typed("9941_story_frozen")
|
|
|
|
|
.expect("read_typed should succeed")
|
|
|
|
|
.expect("item should be present");
|
2026-04-15 17:57:56 +00:00
|
|
|
assert!(
|
2026-04-29 22:12:23 +00:00
|
|
|
matches!(item.stage, crate::pipeline_state::Stage::Coding),
|
|
|
|
|
"stage should be restored to Coding: {:?}",
|
|
|
|
|
item.stage
|
2026-04-15 17:57:56 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unfreeze_command_not_frozen_returns_error() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
2026-04-29 22:12:23 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
|
|
|
|
crate::db::ensure_content_store();
|
2026-04-15 17:57:56 +00:00
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"9942_story_notfrozen.md",
|
|
|
|
|
"---\nname: Not Frozen\n---\n# Story\n",
|
|
|
|
|
);
|
|
|
|
|
let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("not frozen"),
|
|
|
|
|
"should return not-frozen error: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn freeze_command_already_frozen_returns_message() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
2026-04-29 22:12:23 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
|
|
|
|
crate::db::ensure_content_store();
|
2026-04-15 17:57:56 +00:00
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"9943_story_alreadyfrozen.md",
|
2026-04-29 22:12:23 +00:00
|
|
|
"---\nname: Already Frozen\n---\n# Story\n",
|
2026-04-15 17:57:56 +00:00
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
// Freeze it first.
|
|
|
|
|
freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
|
|
|
|
// Try to freeze again.
|
2026-04-15 17:57:56 +00:00
|
|
|
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("already frozen"),
|
|
|
|
|
"should say already frozen: {output}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|