story-kit: merge 327_story_bot_overview_command_shows_implementation_summary_for_a_story

This commit is contained in:
Dave
2026-03-20 01:19:21 +00:00
parent 973af81fa5
commit 14cab448cb

View File

@@ -110,6 +110,11 @@ pub fn commands() -> &'static [BotCommand] {
description: "Display the full text of a work item: `show <number>`", description: "Display the full text of a work item: `show <number>`",
handler: handle_show, handler: handle_show,
}, },
BotCommand {
name: "overview",
description: "Show implementation summary for a merged story: `overview <number>`",
handler: handle_overview,
},
BotCommand { BotCommand {
name: "delete", name: "delete",
description: "Remove a work item from the pipeline: `delete <number>`", description: "Remove a work item from the pipeline: `delete <number>`",
@@ -656,6 +661,222 @@ fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
None 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<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
return Some(format!(
"Usage: `{} overview <number>`\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 <number>`",
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<String> = 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<String> {
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<String> {
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<String> {
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<String> = 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<String> {
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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1590,4 +1811,137 @@ mod tests {
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1"); let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
assert!(result.is_some(), "SHOW should match case-insensitively"); assert!(result.is_some(), "SHOW should match case-insensitively");
} }
// -- overview command ---------------------------------------------------
fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
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<String> {");
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<Mutex<HashMap<String, Vec<u8>>>>;");
assert_eq!(result, Some("`SlackHistory` (type)".to_string()));
}
} }