huskies: merge 895
This commit is contained in:
@@ -2,9 +2,36 @@
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Map a stage to its display section label, or `None` to skip it entirely.
|
||||
///
|
||||
/// This is the single source of truth for the "where does this item appear"
|
||||
/// decision. It mirrors the bucket routing in `http/workflow/pipeline.rs`
|
||||
/// so that chat output and the web UI are always consistent.
|
||||
///
|
||||
/// `Stage::Frozen { resume_to }` is handled recursively: a frozen story
|
||||
/// appears in the same section its `resume_to` stage would land in.
|
||||
pub(crate) fn display_section(s: &Stage) -> Option<&'static str> {
|
||||
match s {
|
||||
Stage::Upcoming | Stage::Backlog => Some("Backlog"),
|
||||
Stage::Coding
|
||||
| Stage::Blocked { .. }
|
||||
| Stage::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
} => Some("In Progress"),
|
||||
Stage::Qa | Stage::ReviewHold { .. } => Some("QA"),
|
||||
Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => {
|
||||
Some("Merge")
|
||||
}
|
||||
Stage::Done { .. } => Some("Done"),
|
||||
Stage::Frozen { resume_to } => display_section(resume_to),
|
||||
Stage::Archived { .. } => None, // other archived variants are hidden
|
||||
}
|
||||
}
|
||||
|
||||
/// Check which dependency numbers from `item.depends_on` are unmet.
|
||||
///
|
||||
/// A dependency is considered met if the dep is in `Done` or `Archived` stage
|
||||
@@ -95,26 +122,24 @@ pub(crate) fn build_status_from_items(
|
||||
|
||||
let mut out = String::from("**Pipeline Status**\n\n");
|
||||
|
||||
// Active pipeline stages to display (Archived is handled separately below).
|
||||
type StagePredicate = fn(&Stage) -> bool;
|
||||
let stage_filters: &[(&str, StagePredicate)] = &[
|
||||
("Backlog", |s| matches!(s, Stage::Backlog)),
|
||||
("In Progress", |s| matches!(s, Stage::Coding)),
|
||||
("QA", |s| matches!(s, Stage::Qa)),
|
||||
("Merge", |s| matches!(s, Stage::Merge { .. })),
|
||||
("Done", |s| matches!(s, Stage::Done { .. })),
|
||||
];
|
||||
// Render each display section in order. Blocked items appear in-place
|
||||
// under their stage section (determined by `display_section`); there is
|
||||
// no separate "Blocked" section. Frozen items appear under the section
|
||||
// their `resume_to` stage maps to.
|
||||
let sections = ["Backlog", "In Progress", "QA", "Merge", "Done"];
|
||||
|
||||
for (label, filter) in stage_filters {
|
||||
let mut stage_items: Vec<&PipelineItem> =
|
||||
items.iter().filter(|i| filter(&i.stage)).collect();
|
||||
stage_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
let count = stage_items.len();
|
||||
for label in sections {
|
||||
let mut section_items: Vec<&PipelineItem> = items
|
||||
.iter()
|
||||
.filter(|i| display_section(&i.stage) == Some(label))
|
||||
.collect();
|
||||
section_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
let count = section_items.len();
|
||||
out.push_str(&format!("**{label}** ({count})\n"));
|
||||
if stage_items.is_empty() {
|
||||
if section_items.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for item in &stage_items {
|
||||
for item in §ion_items {
|
||||
out.push_str(&render_item_line(
|
||||
item,
|
||||
items,
|
||||
@@ -129,26 +154,6 @@ pub(crate) fn build_status_from_items(
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
|
||||
let mut blocked_items: Vec<&PipelineItem> =
|
||||
items.iter().filter(|i| i.stage.is_blocked()).collect();
|
||||
blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
if !blocked_items.is_empty() {
|
||||
out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
|
||||
for item in &blocked_items {
|
||||
out.push_str(&render_item_line(
|
||||
item,
|
||||
items,
|
||||
&active_map,
|
||||
&cost_by_story,
|
||||
&config,
|
||||
&running_merges,
|
||||
&merge_failures,
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Free agents: configured agents not currently running or pending.
|
||||
out.push_str("**Free Agents**\n");
|
||||
if let Some(cfg) = &config {
|
||||
@@ -198,7 +203,8 @@ fn render_item_line(
|
||||
} else {
|
||||
Some(item.name.as_str())
|
||||
};
|
||||
let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id);
|
||||
// Use the typed CRDT stage as the sole source of truth (story 945).
|
||||
let frozen = item.stage.is_frozen();
|
||||
let base_label = super::story_short_label(story_id, name_opt);
|
||||
let display = if frozen {
|
||||
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
|
||||
@@ -219,9 +225,24 @@ fn render_item_line(
|
||||
format!(" *(waiting on: {})*", nums.join(", "))
|
||||
};
|
||||
|
||||
// Merge-stage items get a dedicated breakdown indicator instead of the
|
||||
// generic traffic-light dot.
|
||||
if matches!(item.stage, Stage::Merge { .. }) {
|
||||
// Merge-stage items get dedicated breakdown indicators instead of the
|
||||
// generic traffic-light dot. MergeFailure / MergeFailureFinal items
|
||||
// now also appear in the Merge section (in-place) so they are handled
|
||||
// here alongside normal Merge items.
|
||||
if matches!(
|
||||
item.stage,
|
||||
Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. }
|
||||
) {
|
||||
// MergeFailure and MergeFailureFinal carry their reason directly on
|
||||
// the stage variant — always show ⛔ with the failure snippet.
|
||||
match &item.stage {
|
||||
Stage::MergeFailure { reason, .. } | Stage::MergeFailureFinal { reason } => {
|
||||
let snippet = first_non_empty_snippet(reason, 120);
|
||||
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let in_det_merge = running_merges.contains(story_id);
|
||||
let merge_failure = merge_failures.get(story_id);
|
||||
if in_det_merge {
|
||||
|
||||
@@ -682,3 +682,257 @@ fn snippet_skips_leading_empty_lines() {
|
||||
let result = first_non_empty_snippet(text, 120);
|
||||
assert_eq!(result, "actual error here");
|
||||
}
|
||||
|
||||
// -- AC1: blocked items appear in-place, not in a separate "Blocked" section --
|
||||
|
||||
#[test]
|
||||
fn blocked_item_appears_in_in_progress_not_separate_section() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"42_story_blocked",
|
||||
"Blocked Story",
|
||||
Stage::Blocked {
|
||||
reason: "retry limit exceeded".to_string(),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
// Must NOT appear under a separate "Blocked" section.
|
||||
assert!(
|
||||
!output.contains("**Blocked**"),
|
||||
"output must not have a separate Blocked section: {output}"
|
||||
);
|
||||
|
||||
// Must appear under "In Progress".
|
||||
let in_progress_pos = output
|
||||
.find("**In Progress**")
|
||||
.expect("In Progress section must exist");
|
||||
let qa_pos = output.find("**QA**").expect("QA section must exist");
|
||||
let story_pos = output
|
||||
.find("42 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > in_progress_pos && story_pos < qa_pos,
|
||||
"blocked story should be in In Progress section: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_item_shows_red_dot_in_in_progress_section() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"50_story_blocked_dot",
|
||||
"Blocked With Dot",
|
||||
Stage::Blocked {
|
||||
reason: "too many failures".to_string(),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("\u{1F534} "), // 🔴
|
||||
"blocked story should show red circle emoji: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- AC2: stage counts include blocked items --
|
||||
|
||||
#[test]
|
||||
fn in_progress_count_includes_blocked_items() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![
|
||||
make_item("10_story_coding", "Coding Story", Stage::Coding),
|
||||
make_item(
|
||||
"11_story_blocked",
|
||||
"Blocked Story",
|
||||
Stage::Blocked {
|
||||
reason: "failed".to_string(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
// "In Progress" header should show count of 2 (one coding + one blocked).
|
||||
assert!(
|
||||
output.contains("**In Progress** (2)"),
|
||||
"In Progress count should include blocked items: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- AC4: frozen items appear in-place, with ❄️ indicator --
|
||||
|
||||
#[test]
|
||||
fn frozen_coding_item_appears_in_in_progress_section() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"60_story_frozen",
|
||||
"Frozen Coding Story",
|
||||
Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Coding),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
let in_progress_pos = output
|
||||
.find("**In Progress**")
|
||||
.expect("In Progress section must exist");
|
||||
let qa_pos = output.find("**QA**").expect("QA section must exist");
|
||||
let story_pos = output
|
||||
.find("60 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > in_progress_pos && story_pos < qa_pos,
|
||||
"frozen-coding story should appear in In Progress: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_qa_item_appears_in_qa_section() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"70_story_frozen_qa",
|
||||
"Frozen QA Story",
|
||||
Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Qa),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
let qa_pos = output.find("**QA**").expect("QA section must exist");
|
||||
let merge_pos = output.find("**Merge**").expect("Merge section must exist");
|
||||
let story_pos = output
|
||||
.find("70 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > qa_pos && story_pos < merge_pos,
|
||||
"frozen-QA story should appear in QA section: qa={qa_pos} story={story_pos} merge={merge_pos}\n{output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_item_shows_snowflake_indicator() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"80_story_frozen_flake",
|
||||
"Frozen Flake Story",
|
||||
Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Coding),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("\u{2744}\u{FE0F}"), // ❄️
|
||||
"frozen story should show snowflake prefix: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_and_blocked_use_distinct_indicators() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![
|
||||
make_item(
|
||||
"90_story_blocked_ind",
|
||||
"Blocked Story",
|
||||
Stage::Blocked {
|
||||
reason: "failed".to_string(),
|
||||
},
|
||||
),
|
||||
make_item(
|
||||
"91_story_frozen_ind",
|
||||
"Frozen Story",
|
||||
Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Coding),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
assert!(
|
||||
output.contains("\u{1F534} "), // 🔴 for blocked
|
||||
"blocked story should show red dot: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\u{2744}\u{FE0F}"), // ❄️ for frozen
|
||||
"frozen story should show snowflake: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- merge-failure items appear in Merge section --
|
||||
|
||||
#[test]
|
||||
fn merge_failure_item_appears_in_merge_section_not_blocked() {
|
||||
use crate::pipeline_state::BranchName;
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let items = vec![make_item(
|
||||
"100_story_merge_fail",
|
||||
"Merge Failure Story",
|
||||
Stage::MergeFailure {
|
||||
reason: "conflict in lib.rs".to_string(),
|
||||
feature_branch: BranchName("feature/100".to_string()),
|
||||
commits_ahead: std::num::NonZeroU32::new(1).unwrap(),
|
||||
},
|
||||
)];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_status_from_items(tmp.path(), &agents, &items);
|
||||
|
||||
// Must not be in a separate "Blocked" section.
|
||||
assert!(
|
||||
!output.contains("**Blocked**"),
|
||||
"output must not have a separate Blocked section: {output}"
|
||||
);
|
||||
|
||||
let merge_pos = output.find("**Merge**").expect("Merge section must exist");
|
||||
let done_pos = output.find("**Done**").expect("Done section must exist");
|
||||
let story_pos = output
|
||||
.find("100 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > merge_pos && story_pos < done_pos,
|
||||
"merge-failure story should appear in Merge section: merge={merge_pos} story={story_pos} done={done_pos}\n{output}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.contains("\u{26D4}"), // ⛔
|
||||
"merge failure should show stop sign: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("conflict in lib.rs"),
|
||||
"merge failure reason should be shown: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user