From 14cab448cb3e276e27d5a96d55a794ae2661dc2d Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 01:19:21 +0000 Subject: [PATCH] story-kit: merge 327_story_bot_overview_command_shows_implementation_summary_for_a_story --- server/src/matrix/commands.rs | 354 ++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index 558a72f..7124872 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -110,6 +110,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Display the full text of a work item: `show `", handler: handle_show, }, + BotCommand { + name: "overview", + description: "Show implementation summary for a merged story: `overview `", + handler: handle_overview, + }, BotCommand { name: "delete", description: "Remove a work item from the pipeline: `delete `", @@ -656,6 +661,222 @@ fn handle_delete_fallback(_ctx: &CommandContext) -> Option { None } +/// Show implementation summary for a story identified by its number. +/// +/// Finds the `story-kit: merge {story_id}` commit on master, displays the +/// git diff --stat (files changed with line counts), and extracts key +/// function/struct/type names added or modified in the implementation. +/// Returns a friendly message when no merge commit is found. +fn handle_overview(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} overview `\n\nShows the implementation summary for a story.", + ctx.bot_name + )); + } + if !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Invalid story number: `{num_str}`. Usage: `{} overview `", + ctx.bot_name + )); + } + + let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) { + Some(h) => h, + None => { + return Some(format!( + "No implementation found for story **{num_str}**. \ + It may still be in the backlog or was never merged." + )); + } + }; + + let stat_output = get_commit_stat(ctx.project_root, &commit_hash); + let symbols = extract_diff_symbols(ctx.project_root, &commit_hash); + let story_name = find_story_name(ctx.project_root, num_str); + + let short_hash = &commit_hash[..commit_hash.len().min(8)]; + let mut out = match story_name { + Some(name) => format!("**Overview: Story {num_str} — {name}**\n\n"), + None => format!("**Overview: Story {num_str}**\n\n"), + }; + out.push_str(&format!("Commit: `{short_hash}`\n\n")); + + // Parse stat output: collect per-file lines and the summary line. + let mut file_lines: Vec = Vec::new(); + let mut summary_line = String::new(); + for line in stat_output.lines() { + if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) { + summary_line = line.trim().to_string(); + } else if !line.trim().is_empty() && line.contains('|') { + file_lines.push(line.trim().to_string()); + } + } + + if !summary_line.is_empty() { + out.push_str(&format!("**Changes:** {summary_line}\n")); + } + + if !file_lines.is_empty() { + out.push_str("**Files:**\n"); + for f in file_lines.iter().take(8) { + out.push_str(&format!(" • `{f}`\n")); + } + if file_lines.len() > 8 { + out.push_str(&format!(" … and {} more\n", file_lines.len() - 8)); + } + } + + if !symbols.is_empty() { + out.push_str("\n**Key symbols:**\n"); + for sym in &symbols { + out.push_str(&format!(" • {sym}\n")); + } + } + + Some(out) +} + +/// Find the merge commit hash for a story by its numeric ID. +/// +/// Searches git log for a commit whose subject matches +/// `story-kit: merge {num}_*`. +fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option { + use std::process::Command; + let grep_pattern = format!("story-kit: merge {num_str}_"); + let output = Command::new("git") + .args(["log", "--format=%H", "--all", "--grep", &grep_pattern]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success())?; + let text = String::from_utf8_lossy(&output.stdout); + let hash = text.lines().next()?.trim().to_string(); + if hash.is_empty() { None } else { Some(hash) } +} + +/// Find the human-readable name of a story by searching all pipeline stages. +fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { + let stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + for stage in &stages { + let dir = root.join(".story_kit").join("work").join(stage); + if !dir.exists() { + continue; + } + if let Ok(entries) = std::fs::read_dir(&dir) { + 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 std::fs::read_to_string(&path) + .ok() + .and_then(|c| { + crate::io::story_metadata::parse_front_matter(&c) + .ok() + .and_then(|m| m.name) + }); + } + } + } + } + } + None +} + +/// Return the `git show --stat` output for a commit. +fn get_commit_stat(root: &std::path::Path, hash: &str) -> String { + use std::process::Command; + Command::new("git") + .args(["show", "--stat", hash]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default() +} + +/// Extract up to 12 unique top-level symbol definitions from a commit diff. +/// +/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`, +/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``. +fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec { + use std::process::Command; + let output = Command::new("git") + .args(["show", hash]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + let mut symbols: Vec = Vec::new(); + for line in output.lines() { + if !line.starts_with('+') || line.starts_with("+++") { + continue; + } + if let Some(sym) = parse_symbol_definition(&line[1..]) { + if !symbols.contains(&sym) { + symbols.push(sym); + } + if symbols.len() >= 12 { + break; + } + } + } + symbols +} + +/// Parse a single line of code and return a formatted symbol if it opens a +/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`). +fn parse_symbol_definition(code: &str) -> Option { + let t = code.trim(); + let patterns: &[(&str, &str)] = &[ + ("pub async fn ", "fn"), + ("async fn ", "fn"), + ("pub fn ", "fn"), + ("fn ", "fn"), + ("pub struct ", "struct"), + ("struct ", "struct"), + ("pub enum ", "enum"), + ("enum ", "enum"), + ("pub type ", "type"), + ("type ", "type"), + ("pub trait ", "trait"), + ("trait ", "trait"), + ("impl ", "impl"), + ]; + for (prefix, kind) in patterns { + if let Some(rest) = t.strip_prefix(prefix) { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + return Some(format!("`{name}` ({kind})")); + } + } + } + None +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1590,4 +1811,137 @@ mod tests { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1"); assert!(result.is_some(), "SHOW should match case-insensitively"); } + + // -- overview command --------------------------------------------------- + + fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = test_agents(); + let ambient_rooms = test_ambient_rooms(); + 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, + is_addressed: true, + }; + try_handle_command(&dispatch, &format!("@timmy overview {args}")) + } + + #[test] + fn overview_command_is_registered() { + let found = commands().iter().any(|c| c.name == "overview"); + assert!(found, "overview command must be in the registry"); + } + + #[test] + fn overview_command_appears_in_help() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); + let output = result.unwrap(); + assert!(output.contains("overview"), "help should list overview command: {output}"); + } + + #[test] + fn overview_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = overview_cmd_with_root(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage hint: {output}" + ); + } + + #[test] + fn overview_command_non_numeric_arg_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = overview_cmd_with_root(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error: {output}" + ); + } + + #[test] + fn overview_command_not_found_returns_friendly_message() { + // Use the real repo root but a story number that was never merged. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(std::path::Path::new(".")); + let output = overview_cmd_with_root(repo_root, "99999").unwrap(); + assert!( + output.contains("99999"), + "not-found message should include the story number: {output}" + ); + assert!( + output.contains("backlog") || output.contains("No implementation"), + "not-found message should explain why: {output}" + ); + } + + #[test] + fn overview_command_found_shows_commit_and_stat() { + // Story 324 has a real merge commit in master. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(std::path::Path::new(".")); + let output = overview_cmd_with_root(repo_root, "324").unwrap(); + assert!( + output.contains("**Overview: Story 324"), + "output should show story header: {output}" + ); + assert!( + output.contains("Commit:"), + "output should show commit hash: {output}" + ); + assert!( + output.contains("**Changes:**") || output.contains("**Files:**"), + "output should show file changes: {output}" + ); + } + + #[test] + fn overview_command_case_insensitive() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy OVERVIEW 1"); + assert!(result.is_some(), "OVERVIEW should match case-insensitively"); + } + + // -- parse_symbol_definition -------------------------------------------- + + #[test] + fn parse_symbol_pub_fn() { + let result = parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option {"); + assert_eq!(result, Some("`handle_foo` (fn)".to_string())); + } + + #[test] + fn parse_symbol_pub_struct() { + let result = parse_symbol_definition("pub struct SlackTransport {"); + assert_eq!(result, Some("`SlackTransport` (struct)".to_string())); + } + + #[test] + fn parse_symbol_impl() { + let result = parse_symbol_definition("impl ChatTransport for SlackTransport {"); + assert_eq!(result, Some("`ChatTransport` (impl)".to_string())); + } + + #[test] + fn parse_symbol_no_match() { + let result = parse_symbol_definition(" let x = 42;"); + assert_eq!(result, None); + } + + #[test] + fn parse_symbol_pub_enum() { + let result = parse_symbol_definition("pub enum QaMode {"); + assert_eq!(result, Some("`QaMode` (enum)".to_string())); + } + + #[test] + fn parse_symbol_pub_type() { + let result = parse_symbol_definition("pub type SlackHistory = Arc>>>;"); + assert_eq!(result, Some("`SlackHistory` (type)".to_string())); + } }