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
+12 -60
View File
@@ -1,76 +1,28 @@
{
"enabledMcpjsonServers": [
"huskies"
],
"permissions": {
"allow": [
"Bash(./server/target/debug/huskies:*)",
"Bash(./target/debug/huskies:*)",
"Bash(HUSKIES_PORT=*)",
"Bash(cargo build:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo doc:*)",
"Bash(cargo llvm-cov:*)",
"Bash(cargo nextest run:*)",
"Bash(cargo run:*)",
"Bash(cargo test:*)",
"Bash(cargo watch:*)",
"Bash(cd *)",
"Bash(claude:*)",
"Bash(curl:*)",
"Bash(echo:*)",
"Bash(env:*)",
"Bash(git *)",
"Bash(grep:*)",
"Bash(kill *)",
"Bash(ls *)",
"Bash(lsof *)",
"Bash(mkdir *)",
"Bash(mv *)",
"Bash(npm run build:*)",
"Bash(npx @biomejs/biome check:*)",
"Bash(npx @playwright/test test:*)",
"Bash(npx biome check:*)",
"Bash(npx playwright test:*)",
"Bash(npx tsc:*)",
"Bash(npx vitest:*)",
"Bash(pnpm add:*)",
"Bash(pnpm build:*)",
"Bash(pnpm dev:*)",
"Bash(pnpm install:*)",
"Bash(pnpm run build:*)",
"Bash(pnpm run test:*)",
"Bash(pnpm test:*)",
"Bash(printf:*)",
"Bash(ps *)",
"Bash(python3:*)",
"Bash(pwd *)",
"Bash(rm *)",
"Bash(sleep *)",
"Bash(touch *)",
"Bash(xargs:*)",
"WebFetch(domain:crates.io)",
"WebFetch(domain:docs.rs)",
"WebFetch(domain:github.com)",
"WebFetch(domain:portkey.ai)",
"WebFetch(domain:www.shuttle.dev)",
"WebSearch",
"mcp__huskies__*",
"Edit",
"Write",
"Bash(echo:*)",
"Bash(pwd *)",
"Bash(grep:*)",
"Bash(find *)",
"Bash(sqlite3 *)",
"Bash(cat <<:*)",
"Bash(cat <<'ENDJSON:*)",
"Bash(make release:*)",
"Bash(npm test:*)",
"Bash(head *)",
"Bash(tail *)",
"Bash(wc *)",
"Bash(npx vite:*)",
"Bash(npm run dev:*)",
"Bash(stat *)"
"Bash(cat *)",
"Edit",
"Write",
"mcp__huskies__*"
]
}
}
},
"enabledMcpjsonServers": [
"huskies"
]
}
+4
View File
@@ -8,6 +8,10 @@ export const SLASH_COMMANDS: SlashCommand[] = [
name: "/help",
description: "Show this list of available slash commands.",
},
{
name: "/backlog",
description: "Show all items in the backlog with dependency satisfaction status.",
},
{
name: "/status",
description:
+12 -4
View File
@@ -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!(
"{}{}",
+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}");
}
}
+6
View File
@@ -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",
+1 -1
View File
@@ -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| {