Files
storkit/server/src/matrix/commands/whatsup.rs
dave f610ef6046 Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:07:07 +00:00

549 lines
18 KiB
Rust

//! 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<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} whatsup <number>`\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 <number>`",
ctx.bot_name
));
}
let current_dir = ctx
.project_root
.join(".storkit")
.join("work")
.join("2_current");
match find_story_in_dir(&current_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<String> = 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<PathBuf> {
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<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 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::<Vec<_>>().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);
}
}