//! Handler for the `whatsup` command. //! //! Produces a triage dump for a story that is currently in-progress //! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state, //! git diff, recent commits, and the tail of the agent log. //! //! The command is handled entirely at the bot level — no LLM invocation. use super::CommandContext; use std::path::{Path, PathBuf}; use std::process::Command; /// Handle `{bot_name} whatsup {number}`. pub(super) fn handle_whatsup(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() { return Some(format!( "Usage: `{} whatsup `\n\nShows a triage dump for a story currently in progress.", ctx.bot_name )); } if !num_str.chars().all(|c| c.is_ascii_digit()) { return Some(format!( "Invalid story number: `{num_str}`. Usage: `{} whatsup `", ctx.bot_name )); } let current_dir = ctx .project_root .join(".storkit") .join("work") .join("2_current"); match find_story_in_dir(¤t_dir, num_str) { Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)), None => Some(format!( "Story **{num_str}** is not currently in progress (not found in `work/2_current/`)." )), } } /// Find a `.md` file whose numeric prefix matches `num_str` in `dir`. /// /// Returns `(path, file_stem)` for the first match. fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> { let entries = std::fs::read_dir(dir).ok()?; 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 == num_str { return Some((path.clone(), stem.to_string())); } } } None } /// Build the full triage dump for a story. fn build_triage_dump( ctx: &CommandContext, story_path: &Path, story_id: &str, num_str: &str, ) -> String { let contents = match std::fs::read_to_string(story_path) { Ok(c) => c, Err(e) => return format!("Failed to read story {num_str}: {e}"), }; let meta = crate::io::story_metadata::parse_front_matter(&contents).ok(); let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)"); let mut out = String::new(); // ---- Header ---- out.push_str(&format!("## Story {num_str} — {name}\n")); out.push_str("**Stage:** In Progress (`2_current`)\n\n"); // ---- Front matter fields ---- if let Some(ref m) = meta { let mut fields: Vec = Vec::new(); if let Some(true) = m.blocked { fields.push("**blocked:** true".to_string()); } if let Some(ref agent) = m.agent { fields.push(format!("**agent:** {agent}")); } if let Some(ref qa) = m.qa { fields.push(format!("**qa:** {qa}")); } if let Some(true) = m.review_hold { fields.push("**review_hold:** true".to_string()); } if let Some(rc) = m.retry_count && rc > 0 { fields.push(format!("**retry_count:** {rc}")); } if let Some(ref cb) = m.coverage_baseline { fields.push(format!("**coverage_baseline:** {cb}")); } if let Some(ref mf) = m.merge_failure { fields.push(format!("**merge_failure:** {mf}")); } if !fields.is_empty() { out.push_str("**Front matter:**\n"); for f in &fields { out.push_str(&format!(" • {f}\n")); } out.push('\n'); } } // ---- Acceptance criteria ---- let criteria = parse_acceptance_criteria(&contents); if !criteria.is_empty() { out.push_str("**Acceptance Criteria:**\n"); for (checked, text) in &criteria { let mark = if *checked { "✅" } else { "⬜" }; out.push_str(&format!(" {mark} {text}\n")); } let total = criteria.len(); let done = criteria.iter().filter(|(c, _)| *c).count(); out.push_str(&format!(" *{done}/{total} complete*\n")); out.push('\n'); } // ---- Worktree and branch ---- let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id); let branch = format!("feature/story-{story_id}"); if wt_path.is_dir() { out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display())); out.push_str(&format!("**Branch:** `{branch}`\n\n")); // ---- git diff --stat ---- let diff_stat = run_git( &wt_path, &["diff", "--stat", "master...HEAD"], ); if !diff_stat.is_empty() { out.push_str("**Diff stat (vs master):**\n```\n"); out.push_str(&diff_stat); out.push_str("```\n\n"); } else { out.push_str("**Diff stat (vs master):** *(no changes)*\n\n"); } // ---- Last 5 commits on feature branch ---- let log = run_git( &wt_path, &[ "log", "master..HEAD", "--pretty=format:%h %s", "-5", ], ); if !log.is_empty() { out.push_str("**Recent commits (branch only):**\n```\n"); out.push_str(&log); out.push_str("\n```\n\n"); } else { out.push_str("**Recent commits (branch only):** *(none yet)*\n\n"); } } else { out.push_str(&format!("**Branch:** `{branch}`\n")); out.push_str("**Worktree:** *(not yet created)*\n\n"); } // ---- Agent log tail ---- let log_dir = ctx .project_root .join(".storkit") .join("logs") .join(story_id); match latest_log_file(&log_dir) { Some(log_path) => { let tail = read_log_tail(&log_path, 20); let filename = log_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("agent.log"); if tail.is_empty() { out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n")); } else { out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n")); out.push_str(&tail); out.push_str("\n```\n"); } } None => { out.push_str("**Agent log:** *(no log found)*\n"); } } out } /// Parse acceptance criteria from story markdown. /// /// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line. fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> { contents .lines() .filter_map(|line| { let trimmed = line.trim(); if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) { Some((true, text.to_string())) } else { trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string())) } }) .collect() } /// Run a git command in the given directory, returning trimmed stdout (or empty on error). fn run_git(dir: &Path, args: &[&str]) -> String { Command::new("git") .args(args) .current_dir(dir) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default() } /// Find the most recently modified `.log` file in the given directory, /// regardless of agent name. fn latest_log_file(log_dir: &Path) -> Option { if !log_dir.is_dir() { return None; } let mut best: Option<(PathBuf, std::time::SystemTime)> = None; for entry in std::fs::read_dir(log_dir).ok()?.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("log") { continue; } let modified = match entry.metadata().and_then(|m| m.modified()) { Ok(t) => t, Err(_) => continue, }; if best.as_ref().is_none_or(|(_, t)| modified > *t) { best = Some((path, modified)); } } best.map(|(p, _)| p) } /// Read the last `n` non-empty lines from a file as a single string. fn read_log_tail(path: &Path, n: usize) -> String { let contents = match std::fs::read_to_string(path) { Ok(c) => c, Err(_) => return String::new(), }; let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect(); let start = lines.len().saturating_sub(n); lines[start..].join("\n") } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use super::super::{CommandDispatch, try_handle_command}; fn whatsup_cmd(root: &Path, args: &str) -> Option { 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 whatsup {args}")) } fn write_story_file(root: &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(); } // -- registration ------------------------------------------------------- #[test] fn whatsup_command_is_registered() { let found = super::super::commands().iter().any(|c| c.name == "whatsup"); assert!(found, "whatsup command must be in the registry"); } #[test] fn whatsup_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("whatsup"), "help should list whatsup command: {output}" ); } // -- input validation --------------------------------------------------- #[test] fn whatsup_no_args_returns_usage() { let tmp = tempfile::TempDir::new().unwrap(); let output = whatsup_cmd(tmp.path(), "").unwrap(); assert!( output.contains("Usage"), "no args should show usage: {output}" ); } #[test] fn whatsup_non_numeric_returns_error() { let tmp = tempfile::TempDir::new().unwrap(); let output = whatsup_cmd(tmp.path(), "abc").unwrap(); assert!( output.contains("Invalid"), "non-numeric arg should return error: {output}" ); } // -- not found ---------------------------------------------------------- #[test] fn whatsup_story_not_in_current_returns_friendly_message() { let tmp = tempfile::TempDir::new().unwrap(); // Create the directory but put the story in backlog, not current write_story_file( tmp.path(), "1_backlog", "42_story_not_in_current.md", "---\nname: Not in current\n---\n", ); let output = whatsup_cmd(tmp.path(), "42").unwrap(); assert!( output.contains("42"), "message should include story number: {output}" ); assert!( output.contains("not") || output.contains("Not"), "message should say not found/in progress: {output}" ); } // -- found in 2_current ------------------------------------------------- #[test] fn whatsup_shows_story_name_and_stage() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "99_story_my_feature.md", "---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n", ); let output = whatsup_cmd(tmp.path(), "99").unwrap(); assert!(output.contains("99"), "should show story number: {output}"); assert!( output.contains("My Feature"), "should show story name: {output}" ); assert!( output.contains("In Progress") || output.contains("2_current"), "should show pipeline stage: {output}" ); } #[test] fn whatsup_shows_acceptance_criteria() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "99_story_criteria_test.md", "---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n", ); let output = whatsup_cmd(tmp.path(), "99").unwrap(); assert!( output.contains("First thing"), "should show unchecked criterion: {output}" ); assert!( output.contains("Done thing"), "should show checked criterion: {output}" ); // 1 of 3 done assert!( output.contains("1/3"), "should show checked/total count: {output}" ); } #[test] fn whatsup_shows_blocked_field() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "55_story_blocked_story.md", "---\nname: Blocked Story\nblocked: true\n---\n", ); let output = whatsup_cmd(tmp.path(), "55").unwrap(); assert!( output.contains("blocked"), "should show blocked field: {output}" ); } #[test] fn whatsup_shows_agent_field() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "55_story_agent_story.md", "---\nname: Agent Story\nagent: coder-1\n---\n", ); let output = whatsup_cmd(tmp.path(), "55").unwrap(); assert!( output.contains("coder-1"), "should show agent field: {output}" ); } #[test] fn whatsup_no_worktree_shows_not_created() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "77_story_no_worktree.md", "---\nname: No Worktree\n---\n", ); let output = whatsup_cmd(tmp.path(), "77").unwrap(); // Branch name should still appear assert!( output.contains("feature/story-77"), "should show branch name: {output}" ); } #[test] fn whatsup_no_log_shows_no_log_message() { let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "77_story_no_log.md", "---\nname: No Log\n---\n", ); let output = whatsup_cmd(tmp.path(), "77").unwrap(); assert!( output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"), "should indicate no log exists: {output}" ); } // -- parse_acceptance_criteria ------------------------------------------ #[test] fn parse_criteria_mixed() { let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n"; let result = parse_acceptance_criteria(input); assert_eq!(result.len(), 4); assert_eq!(result[0], (false, "First".to_string())); assert_eq!(result[1], (true, "Done".to_string())); assert_eq!(result[2], (true, "Also done".to_string())); assert_eq!(result[3], (false, "Last".to_string())); } #[test] fn parse_criteria_empty() { let input = "# Story\nNo checkboxes here.\n"; let result = parse_acceptance_criteria(input); assert!(result.is_empty()); } // -- read_log_tail ------------------------------------------------------- #[test] fn read_log_tail_returns_last_n_lines() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("test.log"); let content = (1..=30).map(|i| format!("line {i}")).collect::>().join("\n"); std::fs::write(&path, &content).unwrap(); let tail = read_log_tail(&path, 5); let lines: Vec<&str> = tail.lines().collect(); assert_eq!(lines.len(), 5); assert_eq!(lines[0], "line 26"); assert_eq!(lines[4], "line 30"); } #[test] fn read_log_tail_fewer_lines_than_n() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("short.log"); std::fs::write(&path, "line A\nline B\n").unwrap(); let tail = read_log_tail(&path, 20); assert!(tail.contains("line A")); assert!(tail.contains("line B")); } // -- latest_log_file ---------------------------------------------------- #[test] fn latest_log_file_returns_none_for_missing_dir() { let tmp = tempfile::TempDir::new().unwrap(); let result = latest_log_file(&tmp.path().join("nonexistent")); assert!(result.is_none()); } #[test] fn latest_log_file_finds_log() { let tmp = tempfile::TempDir::new().unwrap(); let log_path = tmp.path().join("coder-1-sess-abc.log"); std::fs::write(&log_path, "some log content\n").unwrap(); let result = latest_log_file(tmp.path()); assert!(result.is_some()); assert_eq!(result.unwrap(), log_path); } }