storkit: merge 386_story_unreleased_command_shows_list_of_stories_since_last_release

This commit is contained in:
dave
2026-03-24 22:20:19 +00:00
parent a9aa88b655
commit 11bbfca3da
2 changed files with 314 additions and 0 deletions

View File

@@ -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,
},
]
}

View File

@@ -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<String> {
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<String> {
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<String> {
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<String> = slug
.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + 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<String> {
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<String> {
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");
}
}