huskies: merge 1034
This commit is contained in:
@@ -18,22 +18,14 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
|
|||||||
let args = ctx.args.trim();
|
let args = ctx.args.trim();
|
||||||
|
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} depends <number> [dep1 dep2 ...]`\n\nExamples:\n\
|
|
||||||
• `{0} depends 484 477 478` — set depends_on: [477, 478]\n\
|
|
||||||
• `{0} depends 484` — clear all dependencies",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut parts = args.split_whitespace();
|
let mut parts = args.split_whitespace();
|
||||||
let num_str = parts.next().unwrap_or("");
|
let num_str = parts.next().unwrap_or("");
|
||||||
|
|
||||||
if !num_str.chars().all(|c| c.is_ascii_digit()) || num_str.is_empty() {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} depends <number> [dep1 dep2 ...]`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse dependency numbers.
|
// Parse dependency numbers.
|
||||||
@@ -129,22 +121,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn depends_no_args_returns_usage() {
|
fn depends_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = depends_cmd_with_root(tmp.path(), "").unwrap();
|
let result = depends_cmd_with_root(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage: {output}"
|
"no args should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn depends_non_numeric_number_returns_error() {
|
fn depends_non_numeric_number_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = depends_cmd_with_root(tmp.path(), "abc 1 2").unwrap();
|
let result = depends_cmd_with_root(tmp.path(), "on 477 and 478");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid story number"),
|
result.is_none(),
|
||||||
"non-numeric story number should error: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depends_well_formed_runs_handler() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = depends_cmd_with_root(tmp.path(), "999");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric story number should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,8 @@ use std::process::Command;
|
|||||||
/// Usage: `diff <number>`
|
/// Usage: `diff <number>`
|
||||||
pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} diff <number>`\n\nShows the git diff from the main branch to the story's worktree HEAD.",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Some(format!(
|
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} diff <number>`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let story_id = match find_story_id(num_str) {
|
let story_id = match find_story_id(num_str) {
|
||||||
@@ -169,22 +160,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_command_no_args_returns_usage() {
|
fn diff_command_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = diff_cmd(tmp.path(), "").unwrap();
|
let result = diff_cmd(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage: {output}"
|
"no args should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_command_non_numeric_returns_error() {
|
fn diff_command_non_numeric_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = diff_cmd(tmp.path(), "abc").unwrap();
|
let result = diff_cmd(tmp.path(), "the login feature branch");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid"),
|
result.is_none(),
|
||||||
"non-numeric arg should return error: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_command_well_formed_runs_handler() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = diff_cmd(tmp.path(), "99994");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ use std::path::Path;
|
|||||||
pub(super) fn handle_freeze(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_freeze(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} freeze <number>` (e.g. `freeze 42`)",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Some(freeze_by_number(ctx.effective_root(), num_str))
|
Some(freeze_by_number(ctx.effective_root(), num_str))
|
||||||
}
|
}
|
||||||
@@ -57,10 +54,7 @@ fn freeze_by_story_id(story_id: &str) -> String {
|
|||||||
pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} unfreeze <number>` (e.g. `unfreeze 42`)",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Some(unfreeze_by_number(ctx.effective_root(), num_str))
|
Some(unfreeze_by_number(ctx.effective_root(), num_str))
|
||||||
}
|
}
|
||||||
@@ -155,22 +149,62 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn freeze_command_no_args_returns_usage() {
|
fn freeze_command_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = freeze_cmd_with_root(tmp.path(), "").unwrap();
|
let result = freeze_cmd_with_root(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage: {output}"
|
"no args should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unfreeze_command_no_args_returns_usage() {
|
fn freeze_command_non_numeric_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap();
|
let result = freeze_cmd_with_root(tmp.path(), "the login story");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn freeze_command_well_formed_runs_handler() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = freeze_cmd_with_root(tmp.path(), "999");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfreeze_command_no_args_routes_to_timmy() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = unfreeze_cmd_with_root(tmp.path(), "");
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"no args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfreeze_command_non_numeric_routes_to_timmy() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = unfreeze_cmd_with_root(tmp.path(), "the login story");
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfreeze_command_well_formed_runs_handler() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = unfreeze_cmd_with_root(tmp.path(), "999");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,8 @@ use std::path::{Path, PathBuf};
|
|||||||
/// recently modified agent log file for that story.
|
/// recently modified agent log file for that story.
|
||||||
pub(super) fn handle_logs(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_logs(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} logs <number>`\n\nShows the last agent log lines for a story.",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Some(format!(
|
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} logs <number>`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let story_id = match find_story_id_by_number(num_str) {
|
let story_id = match find_story_id_by_number(num_str) {
|
||||||
@@ -155,22 +146,32 @@ mod tests {
|
|||||||
// -- input validation ---------------------------------------------------
|
// -- input validation ---------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn logs_no_args_returns_usage() {
|
fn logs_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = logs_cmd(tmp.path(), "").unwrap();
|
let result = logs_cmd(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage: {output}"
|
"no args should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn logs_non_numeric_returns_error() {
|
fn logs_non_numeric_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = logs_cmd(tmp.path(), "abc").unwrap();
|
let result = logs_cmd(tmp.path(), "for the login story");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid"),
|
result.is_none(),
|
||||||
"non-numeric arg should return error: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logs_well_formed_runs_handler() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = logs_cmd(tmp.path(), "99999");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -495,6 +495,84 @@ pub(crate) mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- malformed-args routing (story 1034) -----------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_unblock_args_route_to_timmy() {
|
||||||
|
// "unblock to fix the blocking issue" — verb recognised, args look like
|
||||||
|
// natural language rather than a numeric story ID. Must return None so
|
||||||
|
// the message is forwarded to the LLM instead of showing a usage error.
|
||||||
|
let result = try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy unblock to fix the blocking issue",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"unblock with natural-language args must route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_start_args_route_to_timmy_via_registry() {
|
||||||
|
// "start to get the cheese grater working" — the registry entry for
|
||||||
|
// start always returns None (handled async), so even bad args produce None.
|
||||||
|
let result = try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy start to get the cheese grater working",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"start with natural-language args must route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_show_args_route_to_timmy() {
|
||||||
|
let result = try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy show me the login bug",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"show with natural-language args must route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_freeze_args_route_to_timmy() {
|
||||||
|
let result = try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy freeze the pipeline until Friday",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"freeze with natural-language args must route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_unblock_runs_handler() {
|
||||||
|
// Numeric story ID → command handler runs (story not found, but Some is returned).
|
||||||
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy unblock 1010");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed unblock command should run the handler: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_show_runs_handler() {
|
||||||
|
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy show 984");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed show command should run the handler: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -- commands registry --------------------------------------------------
|
// -- commands registry --------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,19 +22,21 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
|
|||||||
let (num_str, stage_raw) = match args.split_once(char::is_whitespace) {
|
let (num_str, stage_raw) = match args.split_once(char::is_whitespace) {
|
||||||
Some((n, s)) => (n.trim(), s.trim()),
|
Some((n, s)) => (n.trim(), s.trim()),
|
||||||
None => {
|
None => {
|
||||||
return Some(format!(
|
// No stage argument: if args looks like a number show usage, otherwise
|
||||||
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
|
// fall through to the LLM (looks like natural language).
|
||||||
ctx.services.bot_name,
|
if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) {
|
||||||
VALID_STAGES.join(", ")
|
return Some(format!(
|
||||||
));
|
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
|
||||||
|
ctx.services.bot_name,
|
||||||
|
VALID_STAGES.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_stage = stage_raw.to_ascii_lowercase();
|
let target_stage = stage_raw.to_ascii_lowercase();
|
||||||
@@ -113,12 +115,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_command_no_args_returns_usage() {
|
fn move_command_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = move_cmd_with_root(tmp.path(), "").unwrap();
|
let result = move_cmd_with_root(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage hint: {output}"
|
"no args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_command_natural_language_args_routes_to_timmy() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = move_cmd_with_root(tmp.path(), "the auth story to done");
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +140,7 @@ mod tests {
|
|||||||
let output = move_cmd_with_root(tmp.path(), "42").unwrap();
|
let output = move_cmd_with_root(tmp.path(), "42").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
output.contains("Usage"),
|
||||||
"missing stage should show usage hint: {output}"
|
"numeric number without stage should show usage hint: {output}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,12 +155,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_command_non_numeric_number_returns_error() {
|
fn move_command_non_numeric_number_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = move_cmd_with_root(tmp.path(), "abc current").unwrap();
|
let result = move_cmd_with_root(tmp.path(), "abc current");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid story number"),
|
result.is_none(),
|
||||||
"non-numeric number should return error: {output}"
|
"non-numeric story number should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,8 @@ use super::CommandContext;
|
|||||||
#[allow(clippy::string_slice)] // commit_hash is hex (ASCII), min(8) always within bounds
|
#[allow(clippy::string_slice)] // commit_hash is hex (ASCII), min(8) always within bounds
|
||||||
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Some(format!(
|
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} overview <number>`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let commit_hash = match find_story_merge_commit(ctx.effective_root(), num_str) {
|
let commit_hash = match find_story_merge_commit(ctx.effective_root(), num_str) {
|
||||||
@@ -232,22 +223,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overview_command_no_args_returns_usage() {
|
fn overview_command_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = overview_cmd_with_root(tmp.path(), "").unwrap();
|
let result = overview_cmd_with_root(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage hint: {output}"
|
"no args should route to LLM (None): {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overview_command_non_numeric_arg_returns_error() {
|
fn overview_command_non_numeric_arg_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = overview_cmd_with_root(tmp.path(), "abc").unwrap();
|
let result = overview_cmd_with_root(tmp.path(), "of the auth refactor");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid"),
|
result.is_none(),
|
||||||
"non-numeric arg should return error: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overview_command_well_formed_runs_handler() {
|
||||||
|
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(std::path::Path::new("."));
|
||||||
|
let result = overview_cmd_with_root(repo_root, "99999");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,17 +69,8 @@ fn strip_front_matter(text: &str) -> (String, String) {
|
|||||||
/// Returns a friendly message when no match is found.
|
/// Returns a friendly message when no match is found.
|
||||||
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} show <number>`\n\nDisplays the full text of a story, bug, or spike.",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Some(format!(
|
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} show <number>`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the story by numeric prefix: CRDT → content store.
|
// Find the story by numeric prefix: CRDT → content store.
|
||||||
@@ -169,22 +160,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_command_no_args_returns_usage() {
|
fn show_command_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = show_cmd_with_root(tmp.path(), "").unwrap();
|
let result = show_cmd_with_root(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage hint: {output}"
|
"no args should route to LLM (None), not return a usage error: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_command_non_numeric_args_returns_error() {
|
fn show_command_non_numeric_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = show_cmd_with_root(tmp.path(), "abc").unwrap();
|
let result = show_cmd_with_root(tmp.path(), "me the story about the login bug");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid"),
|
result.is_none(),
|
||||||
"non-numeric arg should return error message: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_command_well_formed_runs_handler() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = show_cmd_with_root(tmp.path(), "999");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,17 +18,8 @@ use std::process::Command;
|
|||||||
/// Handle `{bot_name} status {number}`.
|
/// Handle `{bot_name} status {number}`.
|
||||||
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} status <number>`\n\nShows pipeline info for a story: stage, ACs, git diff, recent commits.",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Some(format!(
|
|
||||||
"Invalid story number: `{num_str}`. Usage: `{} status <number>`",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match find_story_by_number(num_str) {
|
match find_story_by_number(num_str) {
|
||||||
@@ -276,12 +267,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsup_non_numeric_returns_error() {
|
fn whatsup_non_numeric_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = status_triage_cmd(tmp.path(), "abc").unwrap();
|
let result = status_triage_cmd(tmp.path(), "what is going on");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid"),
|
result.is_none(),
|
||||||
"non-numeric arg should return error: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsup_well_formed_runs_handler() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = status_triage_cmd(tmp.path(), "99996");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
|
|||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
|
|
||||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(format!(
|
return None;
|
||||||
"Usage: `{} unblock <number>` (e.g. `unblock 42`)",
|
|
||||||
ctx.services.bot_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(unblock_by_number(ctx.effective_root(), num_str))
|
Some(unblock_by_number(ctx.effective_root(), num_str))
|
||||||
@@ -152,22 +149,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unblock_command_no_args_returns_usage() {
|
fn unblock_command_no_args_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = unblock_cmd_with_root(tmp.path(), "").unwrap();
|
let result = unblock_cmd_with_root(tmp.path(), "");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"no args should show usage hint: {output}"
|
"no args should route to LLM (None), not return a usage error: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unblock_command_non_numeric_returns_usage() {
|
fn unblock_command_non_numeric_routes_to_timmy() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = unblock_cmd_with_root(tmp.path(), "abc").unwrap();
|
let result = unblock_cmd_with_root(tmp.path(), "to fix the blocking issue");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
result.is_none(),
|
||||||
"non-numeric arg should show usage hint: {output}"
|
"natural-language args should route to LLM (None): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unblock_command_well_formed_runs_handler() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let result = unblock_cmd_with_root(tmp.path(), "666");
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"well-formed numeric arg should run the command handler: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,35 +272,26 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the assign command, which requires async agent ops (stop +
|
// Check for the assign command, which requires async agent ops (stop +
|
||||||
// start) and cannot be handled by the sync command registry.
|
// start) and cannot be handled by the sync command registry.
|
||||||
if let Some(assign_cmd) = super::super::super::assign::extract_assign_command(
|
// Only handle the well-formed variant; BadArgs falls through to the LLM.
|
||||||
|
if let Some(super::super::super::assign::AssignCommand::Assign {
|
||||||
|
story_number,
|
||||||
|
model,
|
||||||
|
}) = super::super::super::assign::extract_assign_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
let response = match assign_cmd {
|
slog!(
|
||||||
super::super::super::assign::AssignCommand::Assign {
|
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
|
||||||
story_number,
|
);
|
||||||
model,
|
let response = super::super::super::assign::handle_assign(
|
||||||
} => {
|
&ctx.services.bot_name,
|
||||||
slog!(
|
&story_number,
|
||||||
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
|
&model,
|
||||||
);
|
&effective_root,
|
||||||
super::super::super::assign::handle_assign(
|
&ctx.services.agents,
|
||||||
&ctx.services.bot_name,
|
)
|
||||||
&story_number,
|
.await;
|
||||||
&model,
|
|
||||||
&effective_root,
|
|
||||||
&ctx.services.agents,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
super::super::super::assign::AssignCommand::BadArgs => {
|
|
||||||
format!(
|
|
||||||
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
|
||||||
ctx.services.bot_name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx
|
if let Ok(msg_id) = ctx
|
||||||
.transport
|
.transport
|
||||||
@@ -346,26 +337,22 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the delete command, which requires async agent/worktree ops
|
// Check for the delete command, which requires async agent/worktree ops
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(del_cmd) = super::super::super::delete::extract_delete_command(
|
// Only handle the well-formed variant; BadArgs falls through to the LLM.
|
||||||
&user_message,
|
if let Some(super::super::super::delete::DeleteCommand::Delete { story_number }) =
|
||||||
&ctx.services.bot_name,
|
super::super::super::delete::extract_delete_command(
|
||||||
ctx.matrix_user_id.as_str(),
|
&user_message,
|
||||||
) {
|
&ctx.services.bot_name,
|
||||||
let response = match del_cmd {
|
ctx.matrix_user_id.as_str(),
|
||||||
super::super::super::delete::DeleteCommand::Delete { story_number } => {
|
)
|
||||||
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
|
{
|
||||||
super::super::super::delete::handle_delete(
|
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
|
||||||
&ctx.services.bot_name,
|
let response = super::super::super::delete::handle_delete(
|
||||||
&story_number,
|
&ctx.services.bot_name,
|
||||||
&effective_root,
|
&story_number,
|
||||||
&ctx.services.agents,
|
&effective_root,
|
||||||
)
|
&ctx.services.agents,
|
||||||
.await
|
)
|
||||||
}
|
.await;
|
||||||
super::super::super::delete::DeleteCommand::BadArgs => {
|
|
||||||
format!("Usage: `{} delete <number>`", ctx.services.bot_name)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx
|
if let Ok(msg_id) = ctx
|
||||||
.transport
|
.transport
|
||||||
@@ -380,26 +367,22 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the rmtree command, which requires async agent/worktree ops
|
// Check for the rmtree command, which requires async agent/worktree ops
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(rmtree_cmd) = super::super::super::rmtree::extract_rmtree_command(
|
// Only handle the well-formed variant; BadArgs falls through to the LLM.
|
||||||
&user_message,
|
if let Some(super::super::super::rmtree::RmtreeCommand::Rmtree { story_number }) =
|
||||||
&ctx.services.bot_name,
|
super::super::super::rmtree::extract_rmtree_command(
|
||||||
ctx.matrix_user_id.as_str(),
|
&user_message,
|
||||||
) {
|
&ctx.services.bot_name,
|
||||||
let response = match rmtree_cmd {
|
ctx.matrix_user_id.as_str(),
|
||||||
super::super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
)
|
||||||
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
|
{
|
||||||
super::super::super::rmtree::handle_rmtree(
|
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
|
||||||
&ctx.services.bot_name,
|
let response = super::super::super::rmtree::handle_rmtree(
|
||||||
&story_number,
|
&ctx.services.bot_name,
|
||||||
&effective_root,
|
&story_number,
|
||||||
&ctx.services.agents,
|
&effective_root,
|
||||||
)
|
&ctx.services.agents,
|
||||||
.await
|
)
|
||||||
}
|
.await;
|
||||||
super::super::super::rmtree::RmtreeCommand::BadArgs => {
|
|
||||||
format!("Usage: `{} rmtree <number>`", ctx.services.bot_name)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx
|
if let Ok(msg_id) = ctx
|
||||||
.transport
|
.transport
|
||||||
@@ -414,35 +397,26 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
|
|
||||||
// Check for the start command, which requires async agent ops and cannot
|
// Check for the start command, which requires async agent ops and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(start_cmd) = super::super::super::start::extract_start_command(
|
// Only handle the well-formed variant; BadArgs falls through to the LLM.
|
||||||
|
if let Some(super::super::super::start::StartCommand::Start {
|
||||||
|
story_number,
|
||||||
|
agent_hint,
|
||||||
|
}) = super::super::super::start::extract_start_command(
|
||||||
&user_message,
|
&user_message,
|
||||||
&ctx.services.bot_name,
|
&ctx.services.bot_name,
|
||||||
ctx.matrix_user_id.as_str(),
|
ctx.matrix_user_id.as_str(),
|
||||||
) {
|
) {
|
||||||
let response = match start_cmd {
|
slog!(
|
||||||
super::super::super::start::StartCommand::Start {
|
"[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
|
||||||
story_number,
|
);
|
||||||
agent_hint,
|
let response = super::super::super::start::handle_start(
|
||||||
} => {
|
&ctx.services.bot_name,
|
||||||
slog!(
|
&story_number,
|
||||||
"[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
|
agent_hint.as_deref(),
|
||||||
);
|
&effective_root,
|
||||||
super::super::super::start::handle_start(
|
&ctx.services.agents,
|
||||||
&ctx.services.bot_name,
|
)
|
||||||
&story_number,
|
.await;
|
||||||
agent_hint.as_deref(),
|
|
||||||
&effective_root,
|
|
||||||
&ctx.services.agents,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
super::super::super::start::StartCommand::BadArgs => {
|
|
||||||
format!(
|
|
||||||
"Usage: `{} start <number>` or `{} start <number> opus`",
|
|
||||||
ctx.services.bot_name, ctx.services.bot_name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let html = markdown_to_html(&response);
|
let html = markdown_to_html(&response);
|
||||||
if let Ok(msg_id) = ctx
|
if let Ok(msg_id) = ctx
|
||||||
.transport
|
.transport
|
||||||
|
|||||||
Reference in New Issue
Block a user