use crate::cli::{BlockedArgs, OutputFormat, resolve_output_format_basic_with_outer_mode};
use crate::config::{
CliOverrides, discover_beads_dir_with_cli, external_project_db_paths, open_storage_with_cli,
should_use_color,
};
use crate::error::Result;
use crate::format::{BlockedIssue, BlockedIssueOutput};
use crate::model::{IssueType, Priority};
use crate::output::{OutputContext, OutputMode};
use crate::storage::SqliteStorage;
use std::path::Path;
use std::str::FromStr;
#[allow(clippy::too_many_lines)]
pub fn execute(
args: &BlockedArgs,
_json: bool,
overrides: &CliOverrides,
outer_ctx: &OutputContext,
) -> Result<()> {
tracing::info!("Fetching blocked issues from cache");
let beads_dir = discover_beads_dir_with_cli(overrides)?;
execute_inner(args, overrides, outer_ctx, &beads_dir, None, None)
}
pub fn execute_with_storage(
args: &BlockedArgs,
overrides: &CliOverrides,
outer_ctx: &OutputContext,
beads_dir: &Path,
storage: &SqliteStorage,
) -> Result<()> {
execute_inner(args, overrides, outer_ctx, beads_dir, Some(storage), None)
}
pub fn execute_with_storage_ctx(
args: &BlockedArgs,
overrides: &CliOverrides,
outer_ctx: &OutputContext,
beads_dir: &Path,
storage_ctx: &crate::config::OpenStorageResult,
) -> Result<()> {
execute_inner(
args,
overrides,
outer_ctx,
beads_dir,
None,
Some(storage_ctx),
)
}
#[allow(clippy::too_many_lines)]
fn execute_inner(
args: &BlockedArgs,
overrides: &CliOverrides,
outer_ctx: &OutputContext,
beads_dir: &Path,
preloaded_storage: Option<&SqliteStorage>,
preloaded_storage_ctx: Option<&crate::config::OpenStorageResult>,
) -> Result<()> {
let owned_storage_ctx = if preloaded_storage.is_some() || preloaded_storage_ctx.is_some() {
None
} else {
Some(open_storage_with_cli(beads_dir, overrides)?)
};
let storage = preloaded_storage
.or_else(|| preloaded_storage_ctx.map(|ctx| &ctx.storage))
.or_else(|| owned_storage_ctx.as_ref().map(|ctx| &ctx.storage))
.expect("blocked should have an open storage handle");
let config_layer =
if let Some(storage_ctx) = preloaded_storage_ctx.or(owned_storage_ctx.as_ref()) {
storage_ctx.load_config(overrides)?
} else {
crate::config::load_config(beads_dir, Some(storage), overrides)?
};
let external_db_paths = external_project_db_paths(&config_layer, beads_dir);
let use_color = should_use_color(&config_layer);
let output_format = resolve_output_format_basic_with_outer_mode(
args.format,
outer_ctx.inherited_output_mode(),
args.robot,
);
let quiet = overrides.quiet.unwrap_or(false);
let ctx = OutputContext::from_output_format(output_format, quiet, !use_color);
let blocked_raw = storage.get_blocked_issues()?;
tracing::debug!(
count = blocked_raw.len(),
"Found {} blocked issues",
blocked_raw.len()
);
let mut blocked_issues: Vec<BlockedIssue> = blocked_raw
.into_iter()
.map(|(issue, blockers)| BlockedIssue {
blocked_by_count: blockers.len(),
blocked_by: blockers,
issue,
})
.collect();
let external_statuses =
storage.resolve_external_dependency_statuses(&external_db_paths, true)?;
let mut external_blockers = storage.external_blockers(&external_statuses)?;
if !external_blockers.is_empty() {
let mut by_id: std::collections::HashMap<String, usize> = blocked_issues
.iter()
.enumerate()
.map(|(idx, bi)| (bi.issue.id.clone(), idx))
.collect();
let mut external_ids_to_fetch = Vec::new();
for (issue_id, blockers) in &external_blockers {
if let Some(idx) = by_id.get(issue_id).copied() {
let entry = &mut blocked_issues[idx];
entry.blocked_by.extend(blockers.clone());
entry.blocked_by.sort();
entry.blocked_by.dedup();
entry.blocked_by_count = entry.blocked_by.len();
} else {
external_ids_to_fetch.push(issue_id.clone());
}
}
if !external_ids_to_fetch.is_empty() {
let fetched_issues = storage.get_issues_by_ids(&external_ids_to_fetch)?;
for issue in fetched_issues {
if !include_in_blocked_list(&issue.status) {
continue;
}
if let Some(blockers) = external_blockers.remove(&issue.id) {
let blocked_by_count = blockers.len();
let issue_id = issue.id.clone();
blocked_issues.push(BlockedIssue {
blocked_by_count,
blocked_by: blockers,
issue,
});
by_id.insert(issue_id, blocked_issues.len() - 1);
}
}
}
}
filter_by_type(&mut blocked_issues, &args.type_)?;
filter_by_priority(&mut blocked_issues, &args.priority)?;
if !args.label.is_empty() {
filter_by_labels(&mut blocked_issues, storage, &args.label)?;
}
sort_blocked_issues(&mut blocked_issues);
if args.limit > 0 && blocked_issues.len() > args.limit {
blocked_issues.truncate(args.limit);
}
for bi in &blocked_issues {
tracing::trace!(
id = %bi.issue.id,
blockers = ?bi.blocked_by,
"Blocked issue: {} blocked by {:?}",
bi.issue.id,
bi.blocked_by
);
}
if matches!(ctx.mode(), OutputMode::Quiet) {
return Ok(());
}
match output_format {
OutputFormat::Json => {
let output: Vec<BlockedIssueOutput> = blocked_issues
.iter()
.map(|bi| BlockedIssueOutput {
blocked_by: bi
.blocked_by
.iter()
.map(|blocker_ref| blocker_id_from_ref(blocker_ref).to_string())
.collect(),
blocked_by_count: bi.blocked_by_count,
created_at: bi.issue.created_at,
created_by: bi.issue.created_by.clone(),
description: bi.issue.description.clone(),
id: bi.issue.id.clone(),
issue_type: bi.issue.issue_type.clone(),
priority: bi.issue.priority,
status: bi.issue.status.clone(),
title: bi.issue.title.clone(),
updated_at: bi.issue.updated_at,
})
.collect();
ctx.json_pretty(&output);
}
OutputFormat::Toon => {
let output: Vec<BlockedIssueOutput> = blocked_issues
.iter()
.map(|bi| BlockedIssueOutput {
blocked_by: bi
.blocked_by
.iter()
.map(|blocker_ref| blocker_id_from_ref(blocker_ref).to_string())
.collect(),
blocked_by_count: bi.blocked_by_count,
created_at: bi.issue.created_at,
created_by: bi.issue.created_by.clone(),
description: bi.issue.description.clone(),
id: bi.issue.id.clone(),
issue_type: bi.issue.issue_type.clone(),
priority: bi.issue.priority,
status: bi.issue.status.clone(),
title: bi.issue.title.clone(),
updated_at: bi.issue.updated_at,
})
.collect();
ctx.toon_with_stats(&output, args.stats);
}
OutputFormat::Text | OutputFormat::Csv => {
let max_width = if args.wrap { ctx.width() } else { 0 };
if matches!(ctx.mode(), OutputMode::Rich) {
render_blocked_rich(&blocked_issues, args.detailed, storage, max_width);
} else {
print_text_output(&blocked_issues, args.detailed, storage, max_width);
}
}
}
Ok(())
}
fn include_in_blocked_list(status: &crate::model::Status) -> bool {
!status.is_terminal()
}
fn sort_blocked_issues(issues: &mut [BlockedIssue]) {
issues.sort_by(|a, b| {
let pa = a.issue.priority.0;
let pb = b.issue.priority.0;
pa.cmp(&pb)
.then_with(|| b.blocked_by_count.cmp(&a.blocked_by_count))
});
}
fn filter_by_type(issues: &mut Vec<BlockedIssue>, types: &[String]) -> Result<()> {
if types.is_empty() {
return Ok(());
}
let parsed = types
.iter()
.map(|t| IssueType::from_str(t))
.collect::<Result<Vec<IssueType>>>()?;
issues.retain(|bi| parsed.contains(&bi.issue.issue_type));
Ok(())
}
fn filter_by_priority(issues: &mut Vec<BlockedIssue>, priorities: &[String]) -> Result<()> {
if priorities.is_empty() {
return Ok(());
}
let parsed = priorities
.iter()
.map(|p| Priority::from_str(p))
.collect::<Result<Vec<Priority>>>()?;
issues.retain(|bi| parsed.contains(&bi.issue.priority));
Ok(())
}
fn filter_by_labels(
issues: &mut Vec<BlockedIssue>,
storage: &crate::storage::SqliteStorage,
labels: &[String],
) -> Result<()> {
let mut filtered = Vec::with_capacity(issues.len());
let issue_ids: Vec<String> = issues.iter().map(|bi| bi.issue.id.clone()).collect();
let labels_map = storage.get_labels_for_issues(&issue_ids)?;
for issue in issues.drain(..) {
let matches = labels_map
.get(&issue.issue.id)
.is_some_and(|issue_labels| labels.iter().all(|l| issue_labels.contains(l)));
if matches {
filtered.push(issue);
}
}
*issues = filtered;
Ok(())
}
fn print_text_output(
blocked_issues: &[BlockedIssue],
verbose: bool,
storage: &crate::storage::SqliteStorage,
max_width: usize,
) {
use crate::format::truncate_title;
if blocked_issues.is_empty() {
println!("✨ No blocked issues");
return;
}
println!("\n🚫 Blocked issues ({}):\n", blocked_issues.len());
for bi in blocked_issues {
let priority = bi.issue.priority.0;
let prefix_len = 10 + bi.issue.id.len();
let title = if max_width == 0 {
bi.issue.title.clone()
} else {
truncate_title(&bi.issue.title, max_width.saturating_sub(prefix_len))
};
println!("[● P{}] {}: {}", priority, bi.issue.id, title);
if verbose {
println!(" Blocked by:");
for blocker_ref in &bi.blocked_by {
let blocker_id = blocker_id_from_ref(blocker_ref);
if let Ok(Some(blocker)) = storage.get_issue(blocker_id) {
let blocker_title = if max_width > 0 {
truncate_title(&blocker.title, max_width.saturating_sub(30))
} else {
blocker.title.clone()
};
println!(
" • {}: {} [P{}] [{}]",
blocker_id, blocker_title, blocker.priority.0, blocker.status
);
} else {
println!(" • {blocker_ref} (not found)");
}
}
} else {
let count = bi.blocked_by.len();
let ids: Vec<&str> = bi
.blocked_by
.iter()
.map(|r| blocker_id_from_ref(r))
.collect();
println!(
" Blocked by {} open dependencies: [{}]",
count,
ids.join(", ")
);
}
}
}
fn blocker_id_from_ref(blocker_ref: &str) -> &str {
blocker_ref
.rsplit_once(':')
.map_or(blocker_ref, |(prefix, _)| prefix)
}
fn render_blocked_rich(
blocked_issues: &[BlockedIssue],
verbose: bool,
storage: &crate::storage::SqliteStorage,
max_width: usize,
) {
use crate::format::truncate_title;
use rich_rust::Text;
use rich_rust::prelude::*;
fn color(name: &str) -> Color {
Color::parse(name).unwrap_or_default()
}
let console = Console::default();
if blocked_issues.is_empty() {
let mut text = Text::new("");
text.append_styled("\u{2728} ", Style::new().color(color("green")));
text.append_styled(
"No blocked issues",
Style::new().bold().color(color("green")),
);
console.print_renderable(&text);
return;
}
let mut header = Text::new("");
header.append_styled("\u{1f6ab} ", Style::new().color(color("red")));
header.append_styled("Blocked issues", Style::new().bold().color(color("red")));
header.append_styled(&format!(" ({})", blocked_issues.len()), Style::new().dim());
console.print_renderable(&header);
console.print("");
for bi in blocked_issues {
let priority = bi.issue.priority.0;
let blocker_count = bi.blocked_by_count;
let count_style = match blocker_count {
1 => Style::new().color(color("yellow")),
2 | 3 => Style::new().color(color("bright_yellow")),
_ => Style::new().color(color("red")),
};
let prefix_len = 6 + bi.issue.id.len() + 2; let suffix_len = 15; let title = if max_width > 0 {
let available = max_width.saturating_sub(prefix_len + suffix_len);
truncate_title(&bi.issue.title, available)
} else {
bi.issue.title.clone()
};
let mut line = Text::new("");
line.append_styled(
&format!("[P{}] ", priority),
Style::new().bold().color(color("magenta")),
);
line.append_styled(&bi.issue.id, Style::new().bold().color(color("cyan")));
line.append(": ");
line.append(&title);
line.append_styled(&format!(" [{} blockers]", blocker_count), count_style);
console.print_renderable(&line);
if verbose {
let mut blocked_label = Text::new("");
blocked_label.append_styled(" Blocked by:", Style::new().dim());
console.print_renderable(&blocked_label);
for blocker_ref in &bi.blocked_by {
let blocker_id = blocker_id_from_ref(blocker_ref);
let mut blocker_line = Text::new("");
blocker_line.append_styled(" \u{2022} ", Style::new().color(color("yellow")));
blocker_line.append_styled(blocker_id, Style::new().color(color("cyan")));
if let Ok(Some(blocker)) = storage.get_issue(blocker_id) {
let blocker_title = if max_width > 0 {
truncate_title(&blocker.title, max_width.saturating_sub(40))
} else {
blocker.title.clone()
};
blocker_line.append(": ");
blocker_line.append(&blocker_title);
blocker_line
.append_styled(&format!(" [P{}]", blocker.priority.0), Style::new().dim());
blocker_line
.append_styled(&format!(" [{}]", blocker.status), Style::new().dim());
} else {
blocker_line.append_styled(" (not found)", Style::new().dim());
}
console.print_renderable(&blocker_line);
}
} else {
let ids: Vec<&str> = bi
.blocked_by
.iter()
.map(|r| blocker_id_from_ref(r))
.collect();
let mut detail = Text::new("");
detail.append_styled(" Blocked by: ", Style::new().dim());
detail.append_styled(
&format!("[{}]", ids.join(", ")),
Style::new().color(color("yellow")),
);
console.print_renderable(&detail);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::BlockedArgs;
use crate::logging::init_test_logging;
use crate::model::{Issue, IssueType, Priority, Status};
use chrono::{TimeZone, Utc};
use tracing::info;
fn make_issue(id: &str, title: &str, priority: i32, issue_type: IssueType) -> Issue {
Issue {
id: id.to_string(),
content_hash: None,
title: title.to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority(priority),
issue_type,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
created_by: None,
updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
fn make_blocked_issue(
id: &str,
title: &str,
priority: i32,
blocker_count: usize,
) -> BlockedIssue {
BlockedIssue {
issue: make_issue(id, title, priority, IssueType::Task),
blocked_by_count: blocker_count,
blocked_by: (0..blocker_count).map(|i| format!("blocker-{i}")).collect(),
}
}
#[test]
fn test_blocked_args_defaults() {
init_test_logging();
info!("test_blocked_args_defaults: starting");
let args = BlockedArgs::default();
assert_eq!(args.limit, 0); assert!(!args.detailed);
assert!(args.type_.is_empty());
assert!(args.priority.is_empty());
assert!(args.label.is_empty());
assert!(!args.robot);
info!("test_blocked_args_defaults: assertions passed");
}
#[test]
fn test_sort_by_priority_then_blocker_count() {
init_test_logging();
info!("test_sort_by_priority_then_blocker_count: starting");
let mut issues = vec![
make_blocked_issue("a", "P2 few blockers", 2, 1),
make_blocked_issue("b", "P1 few blockers", 1, 1),
make_blocked_issue("c", "P1 many blockers", 1, 5),
make_blocked_issue("d", "P0 critical", 0, 2),
];
sort_blocked_issues(&mut issues);
assert_eq!(issues[0].issue.id, "d"); assert_eq!(issues[1].issue.id, "c"); assert_eq!(issues[2].issue.id, "b"); assert_eq!(issues[3].issue.id, "a"); info!("test_sort_by_priority_then_blocker_count: assertions passed");
}
#[test]
fn test_filter_by_type_empty_keeps_all() {
init_test_logging();
info!("test_filter_by_type_empty_keeps_all: starting");
let mut issues = vec![
BlockedIssue {
issue: make_issue("a", "Bug", 2, IssueType::Bug),
blocked_by_count: 1,
blocked_by: vec!["x".to_string()],
},
BlockedIssue {
issue: make_issue("b", "Task", 2, IssueType::Task),
blocked_by_count: 1,
blocked_by: vec!["y".to_string()],
},
];
filter_by_type(&mut issues, &[]).expect("filter types");
assert_eq!(issues.len(), 2);
info!("test_filter_by_type_empty_keeps_all: assertions passed");
}
#[test]
fn test_filter_by_type_filters_correctly() {
init_test_logging();
info!("test_filter_by_type_filters_correctly: starting");
let mut issues = vec![
BlockedIssue {
issue: make_issue("a", "Bug", 2, IssueType::Bug),
blocked_by_count: 1,
blocked_by: vec!["x".to_string()],
},
BlockedIssue {
issue: make_issue("b", "Task", 2, IssueType::Task),
blocked_by_count: 1,
blocked_by: vec!["y".to_string()],
},
BlockedIssue {
issue: make_issue("c", "Feature", 2, IssueType::Feature),
blocked_by_count: 1,
blocked_by: vec!["z".to_string()],
},
];
filter_by_type(&mut issues, &["bug".to_string()]).expect("filter types");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].issue.id, "a");
info!("test_filter_by_type_filters_correctly: assertions passed");
}
#[test]
fn test_include_in_blocked_list_matches_local_blocked_query_statuses() {
assert!(include_in_blocked_list(&Status::Open));
assert!(include_in_blocked_list(&Status::InProgress));
assert!(include_in_blocked_list(&Status::Deferred));
assert!(include_in_blocked_list(&Status::Blocked));
assert!(include_in_blocked_list(&Status::Pinned));
assert!(include_in_blocked_list(&Status::Custom(
"review".to_string()
)));
assert!(!include_in_blocked_list(&Status::Closed));
assert!(!include_in_blocked_list(&Status::Tombstone));
}
#[test]
fn test_filter_by_type_case_insensitive() {
init_test_logging();
info!("test_filter_by_type_case_insensitive: starting");
let mut issues = vec![BlockedIssue {
issue: make_issue("a", "Bug", 2, IssueType::Bug),
blocked_by_count: 1,
blocked_by: vec!["x".to_string()],
}];
filter_by_type(&mut issues, &["BUG".to_string()]).expect("filter types");
assert_eq!(issues.len(), 1);
let mut issues2 = vec![BlockedIssue {
issue: make_issue("a", "Bug", 2, IssueType::Bug),
blocked_by_count: 1,
blocked_by: vec!["x".to_string()],
}];
filter_by_type(&mut issues2, &["Bug".to_string()]).expect("filter types");
assert_eq!(issues2.len(), 1);
info!("test_filter_by_type_case_insensitive: assertions passed");
}
#[test]
fn test_filter_by_type_multiple_types() {
init_test_logging();
info!("test_filter_by_type_multiple_types: starting");
let mut issues = vec![
BlockedIssue {
issue: make_issue("a", "Bug", 2, IssueType::Bug),
blocked_by_count: 1,
blocked_by: vec!["x".to_string()],
},
BlockedIssue {
issue: make_issue("b", "Task", 2, IssueType::Task),
blocked_by_count: 1,
blocked_by: vec!["y".to_string()],
},
BlockedIssue {
issue: make_issue("c", "Feature", 2, IssueType::Feature),
blocked_by_count: 1,
blocked_by: vec!["z".to_string()],
},
];
filter_by_type(&mut issues, &["bug".to_string(), "feature".to_string()])
.expect("filter types");
assert_eq!(issues.len(), 2);
let ids: Vec<_> = issues.iter().map(|i| i.issue.id.as_str()).collect();
assert!(ids.contains(&"a"));
assert!(ids.contains(&"c"));
info!("test_filter_by_type_multiple_types: assertions passed");
}
#[test]
fn test_filter_by_priority_empty_keeps_all() {
init_test_logging();
info!("test_filter_by_priority_empty_keeps_all: starting");
let mut issues = vec![
make_blocked_issue("a", "P0", 0, 1),
make_blocked_issue("b", "P2", 2, 1),
make_blocked_issue("c", "P4", 4, 1),
];
filter_by_priority(&mut issues, &[]).expect("filter priorities");
assert_eq!(issues.len(), 3);
info!("test_filter_by_priority_empty_keeps_all: assertions passed");
}
#[test]
fn test_filter_by_priority_single() {
init_test_logging();
info!("test_filter_by_priority_single: starting");
let mut issues = vec![
make_blocked_issue("a", "P0", 0, 1),
make_blocked_issue("b", "P2", 2, 1),
make_blocked_issue("c", "P4", 4, 1),
];
filter_by_priority(&mut issues, &["2".to_string()]).expect("filter priorities");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].issue.id, "b");
info!("test_filter_by_priority_single: assertions passed");
}
#[test]
fn test_filter_by_priority_multiple() {
init_test_logging();
info!("test_filter_by_priority_multiple: starting");
let mut issues = vec![
make_blocked_issue("a", "P0", 0, 1),
make_blocked_issue("b", "P2", 2, 1),
make_blocked_issue("c", "P4", 4, 1),
];
filter_by_priority(&mut issues, &["0".to_string(), "4".to_string()])
.expect("filter priorities");
assert_eq!(issues.len(), 2);
let ids: Vec<_> = issues.iter().map(|i| i.issue.id.as_str()).collect();
assert!(ids.contains(&"a"));
assert!(ids.contains(&"c"));
info!("test_filter_by_priority_multiple: assertions passed");
}
}