storkit: merge 386_story_unreleased_command_shows_list_of_stories_since_last_release
This commit is contained in:
@@ -15,6 +15,7 @@ mod overview;
|
|||||||
mod show;
|
mod show;
|
||||||
mod status;
|
mod status;
|
||||||
mod triage;
|
mod triage;
|
||||||
|
mod unreleased;
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -152,6 +153,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Rebuild the server binary and restart",
|
description: "Rebuild the server binary and restart",
|
||||||
handler: handle_rebuild_fallback,
|
handler: handle_rebuild_fallback,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "unreleased",
|
||||||
|
description: "Show stories merged to master since the last release tag",
|
||||||
|
handler: unreleased::handle_unreleased,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
308
server/src/chat/transport/matrix/commands/unreleased.rs
Normal file
308
server/src/chat/transport/matrix/commands/unreleased.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user