story-kit: merge 327_story_bot_overview_command_shows_implementation_summary_for_a_story
This commit is contained in:
@@ -110,6 +110,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Display the full text of a work item: `show <number>`",
|
||||
handler: handle_show,
|
||||
},
|
||||
BotCommand {
|
||||
name: "overview",
|
||||
description: "Show implementation summary for a merged story: `overview <number>`",
|
||||
handler: handle_overview,
|
||||
},
|
||||
BotCommand {
|
||||
name: "delete",
|
||||
description: "Remove a work item from the pipeline: `delete <number>`",
|
||||
@@ -656,6 +661,222 @@ fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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<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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user