huskies: merge 541_story_backlog_command_for_chat_and_web_ui_shows_only_backlog_items
This commit is contained in:
@@ -193,10 +193,18 @@ pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> {
|
||||
}
|
||||
|
||||
let mut output = String::from("=== script/test_coverage ===\n");
|
||||
let result = Command::new(&script)
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run script/test_coverage: {e}"))?;
|
||||
let result = match Command::new(&script).current_dir(path).output() {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.raw_os_error() == Some(26) => {
|
||||
// ETXTBSY — retry once after a brief pause.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
Command::new(&script)
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run script/test_coverage: {e}"))?
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to run script/test_coverage: {e}")),
|
||||
};
|
||||
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
mod ambient;
|
||||
mod assign;
|
||||
mod backlog;
|
||||
mod cost;
|
||||
mod coverage;
|
||||
mod depends;
|
||||
@@ -91,6 +92,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
|
||||
handler: assign::handle_assign,
|
||||
},
|
||||
BotCommand {
|
||||
name: "backlog",
|
||||
description: "Show all items in the backlog with dependency satisfaction status",
|
||||
handler: backlog::handle_backlog,
|
||||
},
|
||||
BotCommand {
|
||||
name: "help",
|
||||
description: "Show this list of available commands",
|
||||
|
||||
@@ -77,7 +77,7 @@ pub(super) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool)
|
||||
/// A dependency is considered met if the dep is in `Done` or `Archived` stage
|
||||
/// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was
|
||||
/// archived before the CRDT migration and has no row), it is treated as met.
|
||||
fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec<u32> {
|
||||
pub(super) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec<u32> {
|
||||
item.depends_on
|
||||
.iter()
|
||||
.filter_map(|dep_id| {
|
||||
|
||||
Reference in New Issue
Block a user