huskies: merge 541_story_backlog_command_for_chat_and_web_ui_shows_only_backlog_items

This commit is contained in:
dave
2026-04-12 12:58:51 +00:00
parent 2bdb0eb730
commit b4dbfcbde6
6 changed files with 241 additions and 65 deletions
+206
View File
@@ -0,0 +1,206 @@
//! Handler for the `backlog` command — shows only Stage::Backlog items.
use crate::pipeline_state::{PipelineItem, Stage};
use super::CommandContext;
use super::status::{story_short_label, unmet_deps_from_items};
pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option<String> {
Some(build_backlog_output())
}
/// Build the backlog listing text.
///
/// Reads all items from CRDT via `read_all_typed`, filters to `Stage::Backlog`,
/// and renders each with number, type, name, and dependency satisfaction status.
pub(super) fn build_backlog_output() -> String {
let items = crate::pipeline_state::read_all_typed();
build_backlog_from_items(&items)
}
/// Inner implementation that accepts pre-loaded items for testability.
pub(super) fn build_backlog_from_items(items: &[PipelineItem]) -> String {
let mut backlog: Vec<&PipelineItem> = items
.iter()
.filter(|i| matches!(i.stage, Stage::Backlog))
.collect();
backlog.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
let count = backlog.len();
let mut out = format!("**Backlog** ({count})\n");
if backlog.is_empty() {
out.push_str(" *(none)*\n");
return out;
}
for item in &backlog {
out.push_str(&render_backlog_line(item, items));
}
out
}
/// Render a single backlog line: `⚪ <number> [type] — <name> *(waiting on: X, Y)*`
fn render_backlog_line(item: &PipelineItem, all_items: &[PipelineItem]) -> String {
let story_id = &item.story_id.0;
let name_opt = if item.name.is_empty() {
None
} else {
Some(item.name.as_str())
};
let display = story_short_label(story_id, name_opt);
let unmet = unmet_deps_from_items(item, all_items);
let dep_suffix = if unmet.is_empty() {
String::new()
} else {
let nums: Vec<String> = unmet.iter().map(|n| n.to_string()).collect();
format!(" *(waiting on: {})*", nums.join(", "))
};
format!(" \u{26AA} {display}{dep_suffix}\n") // ⚪
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_state::{Stage, StoryId};
use chrono::Utc;
fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem {
PipelineItem {
story_id: StoryId(id.to_string()),
name: name.to_string(),
stage,
depends_on: Vec::new(),
retry_count: 0,
}
}
fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec<u32>) -> PipelineItem {
PipelineItem {
story_id: StoryId(id.to_string()),
name: name.to_string(),
stage,
depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(),
retry_count: 0,
}
}
// -- AC: shows only Stage::Backlog items ----------------------------------
#[test]
fn backlog_shows_only_backlog_stage_items() {
let items = vec![
make_item("10_story_in_backlog", "In Backlog", Stage::Backlog),
make_item("20_story_in_progress", "In Progress", Stage::Coding),
make_item("30_story_in_qa", "In QA", Stage::Qa),
];
let output = build_backlog_from_items(&items);
assert!(output.contains("In Backlog"), "should show backlog item: {output}");
assert!(!output.contains("In Progress"), "should not show coding items: {output}");
assert!(!output.contains("In QA"), "should not show QA items: {output}");
}
// -- AC: shows number, type, name -----------------------------------------
#[test]
fn backlog_shows_number_type_and_name() {
let items = vec![make_item("42_story_my_feature", "My Feature", Stage::Backlog)];
let output = build_backlog_from_items(&items);
assert!(
output.contains("42 [story] — My Feature"),
"should show number, type, and name: {output}"
);
}
// -- AC: shows depends_on with satisfaction status ------------------------
#[test]
fn backlog_shows_waiting_on_for_unmet_deps() {
let items = vec![
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Backlog, vec![999]),
make_item("999_story_dep", "Dep Story", Stage::Backlog),
];
let output = build_backlog_from_items(&items);
assert!(
output.contains("waiting on: 999"),
"should show unmet dep: {output}"
);
}
#[test]
fn backlog_no_waiting_on_when_dep_is_done() {
let items = vec![
make_item_with_deps("10_story_ready", "Ready Story", Stage::Backlog, vec![999]),
make_item(
"999_story_done",
"Done Dep",
Stage::Done {
merged_at: Utc::now(),
merge_commit: crate::pipeline_state::GitSha("abc".to_string()),
},
),
];
let output = build_backlog_from_items(&items);
assert!(
!output.contains("waiting on"),
"should not show waiting-on when dep is done: {output}"
);
}
#[test]
fn backlog_no_waiting_on_when_no_deps() {
let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)];
let output = build_backlog_from_items(&items);
assert!(!output.contains("waiting on"), "no dep suffix when no deps: {output}");
}
// -- AC: command is registered in the registry ----------------------------
#[test]
fn backlog_command_in_registry() {
let found = super::super::commands()
.iter()
.any(|c| c.name == "backlog");
assert!(found, "backlog must be registered in commands()");
}
#[test]
fn backlog_command_appears_in_help() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy help",
);
let output = result.unwrap_or_default();
assert!(output.contains("backlog"), "backlog should appear in help output: {output}");
}
#[test]
fn backlog_command_matches() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy backlog",
);
assert!(result.is_some(), "backlog command should match and return Some");
}
#[test]
fn backlog_output_contains_backlog_header() {
let result = super::super::tests::try_cmd_addressed(
"Timmy",
"@timmy:homeserver.local",
"@timmy backlog",
);
let output = result.unwrap_or_default();
assert!(output.contains("Backlog"), "backlog output should contain Backlog header: {output}");
}
// -- empty backlog --------------------------------------------------------
#[test]
fn backlog_shows_none_when_empty() {
let items = vec![make_item("1_story_done", "Done", Stage::Coding)];
let output = build_backlog_from_items(&items);
assert!(output.contains("*(none)*"), "should show none when no backlog items: {output}");
}
}