diff --git a/server/src/chat/transport/matrix/commands/mod.rs b/server/src/chat/transport/matrix/commands/mod.rs index a27cedc..0244318 100644 --- a/server/src/chat/transport/matrix/commands/mod.rs +++ b/server/src/chat/transport/matrix/commands/mod.rs @@ -15,6 +15,7 @@ mod overview; mod show; mod status; mod triage; +mod unreleased; use crate::agents::AgentPool; use std::collections::HashSet; @@ -152,6 +153,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Rebuild the server binary and restart", handler: handle_rebuild_fallback, }, + BotCommand { + name: "unreleased", + description: "Show stories merged to master since the last release tag", + handler: unreleased::handle_unreleased, + }, ] } diff --git a/server/src/chat/transport/matrix/commands/unreleased.rs b/server/src/chat/transport/matrix/commands/unreleased.rs new file mode 100644 index 0000000..bec4376 --- /dev/null +++ b/server/src/chat/transport/matrix/commands/unreleased.rs @@ -0,0 +1,308 @@ +//! Handler for the `unreleased` command. +//! +//! Shows a list of stories merged to master since the last release tag. + +use super::CommandContext; + +/// Show stories merged since the last release tag. +/// +/// Finds the most recent git tag, then lists all story merge commits between +/// that tag and HEAD on master. Each entry shows the story number and name. +/// Returns a clear message when there are no unreleased stories or no tags. +pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option { + let root = ctx.project_root; + let tag = find_last_release_tag(root); + + let commits = list_merge_commits_since(root, tag.as_deref()); + + if commits.is_empty() { + let msg = match &tag { + Some(t) => format!( + "No unreleased stories since the last release tag **{t}**." + ), + None => "No release tags found and no story merge commits on master.".to_string(), + }; + return Some(msg); + } + + let mut stories: Vec<(u64, String)> = commits + .iter() + .filter_map(|subject| parse_story_from_subject(subject)) + .collect(); + + // Sort by story number, deduplicate. + stories.sort_by_key(|(n, _)| *n); + stories.dedup_by_key(|(n, _)| *n); + + if stories.is_empty() { + let msg = match &tag { + Some(t) => format!( + "No unreleased stories since the last release tag **{t}**." + ), + None => "No release tags found and no story merge commits on master.".to_string(), + }; + return Some(msg); + } + + // Look up human-readable names for each story. + let mut out = match &tag { + Some(t) => format!("**Unreleased stories since {t}:**\n\n"), + None => "**Unreleased stories (no prior release tag):**\n\n".to_string(), + }; + for (num, slug) in &stories { + let name = find_story_name(root, &num.to_string()) + .unwrap_or_else(|| slug_to_name(slug)); + out.push_str(&format!("- **{num}** — {name}\n")); + } + Some(out) +} + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +/// Return the most recent release tag, or `None` if there are no tags. +/// +/// Uses `git tag --sort=-creatordate` to get the newest tag first. +fn find_last_release_tag(root: &std::path::Path) -> Option { + use std::process::Command; + let output = Command::new("git") + .args(["tag", "--sort=-creatordate"]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success())?; + let text = String::from_utf8_lossy(&output.stdout); + let tag = text.lines().next()?.trim().to_string(); + if tag.is_empty() { None } else { Some(tag) } +} + +/// Return the subjects of all `storkit: merge …` commits reachable from HEAD +/// but not from `since_tag` (or all commits when `since_tag` is `None`). +fn list_merge_commits_since( + root: &std::path::Path, + since_tag: Option<&str>, +) -> Vec { + use std::process::Command; + + let range = match since_tag { + Some(tag) => format!("{tag}..HEAD"), + None => "HEAD".to_string(), + }; + + let output = Command::new("git") + .args([ + "log", + &range, + "--format=%s", + "--extended-regexp", + "--grep", + "(storkit|story-kit): merge [0-9]+_", + ]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()); + + match output { + Some(o) => String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(), + None => Vec::new(), + } +} + +/// Parse a story number and slug from a merge commit subject like +/// `storkit: merge 386_story_unreleased_command`. +/// +/// Returns `(story_number, slug_remainder)` or `None` if the subject doesn't +/// match the expected pattern. +fn parse_story_from_subject(subject: &str) -> Option<(u64, String)> { + // Match "storkit: merge NNN_rest" or "story-kit: merge NNN_rest" + let rest = subject + .strip_prefix("storkit: merge ") + .or_else(|| subject.strip_prefix("story-kit: merge "))?; + + let (num_str, slug) = rest.split_once('_')?; + let num: u64 = num_str.parse().ok()?; + Some((num, slug.to_string())) +} + +/// Convert an underscore-separated slug to a title-case name. +/// +/// Used as a fallback when no pipeline file is found. +fn slug_to_name(slug: &str) -> String { + let words: Vec = slug + .split('_') + .filter(|w| !w.is_empty()) + .map(|w| { + let mut c = w.chars(); + match c.next() { + Some(first) => first.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } + }) + .collect(); + words.join(" ") +} + +/// 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 { + const STAGES: &[&str] = &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + for stage in STAGES { + let dir = root.join(".storkit").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 +} + +// --------------------------------------------------------------------------- +// 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 unreleased_cmd_with_root(root: &std::path::Path) -> Option { + 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, "@timmy unreleased") + } + + #[test] + fn unreleased_command_is_registered() { + let found = super::super::commands().iter().any(|c| c.name == "unreleased"); + assert!(found, "unreleased command must be in the registry"); + } + + #[test] + fn unreleased_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("unreleased"), + "help should list unreleased command: {output}" + ); + } + + #[test] + fn unreleased_command_no_tags_returns_message() { + // A temp dir that is not a git repo — git commands will fail gracefully. + let tmp = tempfile::TempDir::new().unwrap(); + let output = unreleased_cmd_with_root(tmp.path()).unwrap(); + // Should return some message (not panic), either about no tags or no commits. + assert!(!output.is_empty(), "should return a non-empty message: {output}"); + } + + #[test] + fn unreleased_command_real_repo_returns_response() { + // Run against the actual repo root to exercise the git path. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(std::path::Path::new(".")); + let output = unreleased_cmd_with_root(repo_root).unwrap(); + // The response should mention "unreleased" or "no unreleased" — just make + // sure it's non-empty and doesn't panic. + assert!(!output.is_empty(), "should return a non-empty message: {output}"); + } + + #[test] + fn unreleased_command_case_insensitive() { + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy UNRELEASED", + ); + assert!(result.is_some(), "UNRELEASED should match case-insensitively"); + } + + // -- parse_story_from_subject ------------------------------------------ + + #[test] + fn parse_story_storkit_prefix() { + let result = parse_story_from_subject("storkit: merge 386_story_unreleased_command"); + assert_eq!(result, Some((386, "story_unreleased_command".to_string()))); + } + + #[test] + fn parse_story_legacy_prefix() { + let result = parse_story_from_subject("story-kit: merge 42_story_add_feature"); + assert_eq!(result, Some((42, "story_add_feature".to_string()))); + } + + #[test] + fn parse_story_no_match() { + let result = parse_story_from_subject("fix: typo in README"); + assert_eq!(result, None); + } + + #[test] + fn parse_story_no_underscore_after_number() { + let result = parse_story_from_subject("storkit: merge 123"); + assert_eq!(result, None); + } + + // -- slug_to_name -------------------------------------------------- + + #[test] + fn slug_to_name_basic() { + assert_eq!(slug_to_name("story_add_feature"), "Story Add Feature"); + } + + #[test] + fn slug_to_name_single_word() { + assert_eq!(slug_to_name("feature"), "Feature"); + } +}