huskies: merge 541_story_backlog_command_for_chat_and_web_ui_shows_only_backlog_items
This commit is contained in:
+11
-59
@@ -1,76 +1,28 @@
|
|||||||
{
|
{
|
||||||
"enabledMcpjsonServers": [
|
|
||||||
"huskies"
|
|
||||||
],
|
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(./server/target/debug/huskies:*)",
|
|
||||||
"Bash(./target/debug/huskies:*)",
|
|
||||||
"Bash(HUSKIES_PORT=*)",
|
|
||||||
"Bash(cargo build:*)",
|
"Bash(cargo build:*)",
|
||||||
"Bash(cargo check:*)",
|
"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(git *)",
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(kill *)",
|
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
"Bash(lsof *)",
|
|
||||||
"Bash(mkdir *)",
|
"Bash(mkdir *)",
|
||||||
"Bash(mv *)",
|
"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(rm *)",
|
||||||
"Bash(sleep *)",
|
|
||||||
"Bash(touch *)",
|
"Bash(touch *)",
|
||||||
"Bash(xargs:*)",
|
"Bash(echo:*)",
|
||||||
"WebFetch(domain:crates.io)",
|
"Bash(pwd *)",
|
||||||
"WebFetch(domain:docs.rs)",
|
"Bash(grep:*)",
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"WebFetch(domain:portkey.ai)",
|
|
||||||
"WebFetch(domain:www.shuttle.dev)",
|
|
||||||
"WebSearch",
|
|
||||||
"mcp__huskies__*",
|
|
||||||
"Edit",
|
|
||||||
"Write",
|
|
||||||
"Bash(find *)",
|
"Bash(find *)",
|
||||||
"Bash(sqlite3 *)",
|
|
||||||
"Bash(cat <<:*)",
|
|
||||||
"Bash(cat <<'ENDJSON:*)",
|
|
||||||
"Bash(make release:*)",
|
|
||||||
"Bash(npm test:*)",
|
|
||||||
"Bash(head *)",
|
"Bash(head *)",
|
||||||
"Bash(tail *)",
|
"Bash(tail *)",
|
||||||
"Bash(wc *)",
|
"Bash(wc *)",
|
||||||
"Bash(npx vite:*)",
|
"Bash(cat *)",
|
||||||
"Bash(npm run dev:*)",
|
"Edit",
|
||||||
"Bash(stat *)"
|
"Write",
|
||||||
|
"mcp__huskies__*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"enabledMcpjsonServers": [
|
||||||
|
"huskies"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,10 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
name: "/help",
|
name: "/help",
|
||||||
description: "Show this list of available slash commands.",
|
description: "Show this list of available slash commands.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "/backlog",
|
||||||
|
description: "Show all items in the backlog with dependency satisfaction status.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "/status",
|
name: "/status",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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 mut output = String::from("=== script/test_coverage ===\n");
|
||||||
let result = Command::new(&script)
|
let result = match Command::new(&script).current_dir(path).output() {
|
||||||
.current_dir(path)
|
Ok(r) => r,
|
||||||
.output()
|
Err(e) if e.raw_os_error() == Some(26) => {
|
||||||
.map_err(|e| format!("Failed to run script/test_coverage: {e}"))?;
|
// 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!(
|
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 ambient;
|
||||||
mod assign;
|
mod assign;
|
||||||
|
mod backlog;
|
||||||
mod cost;
|
mod cost;
|
||||||
mod coverage;
|
mod coverage;
|
||||||
mod depends;
|
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`)",
|
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
|
||||||
handler: assign::handle_assign,
|
handler: assign::handle_assign,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "backlog",
|
||||||
|
description: "Show all items in the backlog with dependency satisfaction status",
|
||||||
|
handler: backlog::handle_backlog,
|
||||||
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "help",
|
name: "help",
|
||||||
description: "Show this list of available commands",
|
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
|
/// 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
|
/// 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.
|
/// 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
|
item.depends_on
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|dep_id| {
|
.filter_map(|dep_id| {
|
||||||
|
|||||||
Reference in New Issue
Block a user