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>
549 lines
18 KiB
Rust
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(¤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<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);
|
|
}
|
|
}
|