//! 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 `huskies: 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", "(huskies|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 /// `huskies: 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 "huskies: merge NNN_rest" or "story-kit: merge NNN_rest" let rest = subject .strip_prefix("huskies: 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 content store then filesystem. fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { // Try content store first. for id in crate::db::all_content_ids() { let file_num = id.split('_').next().unwrap_or(""); if file_num == num_str && let Some(c) = crate::db::read_content(&id) { return crate::io::story_metadata::parse_front_matter(&c) .ok() .and_then(|m| m.name); } } // Fallback: filesystem scan. const STAGES: &[&str] = &[ "1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived", ]; for stage in STAGES { let dir = root.join(".huskies").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_huskies_prefix() { let result = parse_story_from_subject("huskies: 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("huskies: 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"); } }