Skip to main content

sc/cli/commands/
issue.rs

1//! Issue command implementations.
2
3use crate::cli::{
4    IssueCommands, IssueCreateArgs, IssueDepCommands, IssueLabelCommands, IssueListArgs,
5    IssueUpdateArgs,
6};
7use crate::config::{default_actor, resolve_db_path, resolve_project_path};
8use crate::error::{Error, Result};
9use crate::storage::SqliteStorage;
10use serde::{Deserialize, Serialize};
11use std::io::BufRead;
12use std::path::PathBuf;
13
14/// Input for batch issue creation (from JSON).
15#[derive(Debug, Deserialize)]
16#[serde(rename_all = "camelCase")]
17struct BatchInput {
18    issues: Vec<BatchIssue>,
19    #[serde(default)]
20    dependencies: Option<Vec<BatchDependency>>,
21    #[serde(default)]
22    plan_id: Option<String>,
23}
24
25/// Single issue in batch input.
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "camelCase")]
28struct BatchIssue {
29    title: String,
30    #[serde(default)]
31    description: Option<String>,
32    #[serde(default)]
33    details: Option<String>,
34    #[serde(default)]
35    issue_type: Option<String>,
36    #[serde(default)]
37    priority: Option<i32>,
38    #[serde(default)]
39    parent_id: Option<String>,
40    #[serde(default)]
41    plan_id: Option<String>,
42    #[serde(default)]
43    labels: Option<Vec<String>>,
44}
45
46/// Dependency definition in batch input.
47#[derive(Debug, Deserialize)]
48#[serde(rename_all = "camelCase")]
49struct BatchDependency {
50    issue_index: usize,
51    depends_on_index: usize,
52    #[serde(default)]
53    dependency_type: Option<String>,
54}
55
56/// Output for batch creation.
57#[derive(Debug, Serialize)]
58struct BatchOutput {
59    issues: Vec<BatchIssueResult>,
60    dependencies: Vec<BatchDepResult>,
61}
62
63/// Result for single issue in batch.
64#[derive(Debug, Serialize)]
65struct BatchIssueResult {
66    id: String,
67    short_id: Option<String>,
68    title: String,
69    index: usize,
70}
71
72/// Result for dependency in batch.
73#[derive(Debug, Serialize)]
74struct BatchDepResult {
75    issue_id: String,
76    depends_on_id: String,
77    dependency_type: String,
78}
79
80/// Output for issue create.
81#[derive(Serialize)]
82struct IssueCreateOutput {
83    id: String,
84    short_id: Option<String>,
85    title: String,
86    status: String,
87    priority: i32,
88    issue_type: String,
89}
90
91/// Output for issue list.
92#[derive(Serialize)]
93struct IssueListOutput {
94    issues: Vec<crate::storage::Issue>,
95    count: usize,
96}
97
98/// Execute issue commands.
99pub fn execute(
100    command: &IssueCommands,
101    db_path: Option<&PathBuf>,
102    actor: Option<&str>,
103    json: bool,
104) -> Result<()> {
105    match command {
106        IssueCommands::Create(args) => create(args, db_path, actor, json),
107        IssueCommands::List(args) => list(args, db_path, json),
108        IssueCommands::Show { id } => show(id, db_path, json),
109        IssueCommands::Update(args) => update(args, db_path, actor, json),
110        IssueCommands::Claim { ids } => claim(ids, db_path, actor, json),
111        IssueCommands::Release { ids } => release(ids, db_path, actor, json),
112        IssueCommands::Delete { ids } => delete(ids, db_path, actor, json),
113        IssueCommands::Label { command } => label(command, db_path, actor, json),
114        IssueCommands::Dep { command } => dep(command, db_path, actor, json),
115        IssueCommands::Clone { id, title } => clone_issue(id, title.as_deref(), db_path, actor, json),
116        IssueCommands::Duplicate { id, of } => duplicate(id, of, db_path, actor, json),
117        IssueCommands::Ready { limit } => ready(*limit, db_path, json),
118        IssueCommands::NextBlock { count } => next_block(*count, db_path, actor, json),
119        IssueCommands::Batch { json_input } => batch(json_input, db_path, actor, json),
120        IssueCommands::Count { group_by } => count(group_by, db_path, json),
121        IssueCommands::Stale { days, limit } => stale(*days, *limit, db_path, json),
122        IssueCommands::Blocked { limit } => blocked(*limit, db_path, json),
123        IssueCommands::Complete { ids, reason } => complete(ids, reason.as_deref(), db_path, actor, json),
124    }
125}
126
127fn create(
128    args: &IssueCreateArgs,
129    db_path: Option<&PathBuf>,
130    actor: Option<&str>,
131    json: bool,
132) -> Result<()> {
133    // Handle file-based bulk import
134    if let Some(ref file_path) = args.file {
135        return create_from_file(file_path, db_path, actor, json);
136    }
137
138    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
139        .ok_or(Error::NotInitialized)?;
140
141    if !db_path.exists() {
142        return Err(Error::NotInitialized);
143    }
144
145    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
146
147    // Normalize type via synonym lookup
148    let issue_type = crate::validate::normalize_type(&args.issue_type)
149        .map_err(|(val, suggestion)| {
150            let msg = if let Some(s) = suggestion {
151                format!("Invalid issue type '{val}'. Did you mean '{s}'?")
152            } else {
153                format!("Invalid issue type '{val}'. Valid: task, bug, feature, epic, chore")
154            };
155            Error::InvalidArgument(msg)
156        })?;
157
158    // Normalize priority via synonym lookup
159    let priority = crate::validate::normalize_priority(&args.priority.to_string())
160        .map_err(|(val, suggestion)| {
161            let msg = suggestion.unwrap_or_else(|| format!("Invalid priority '{val}'"));
162            Error::InvalidArgument(msg)
163        })?;
164
165    // Dry-run: preview without writing
166    if crate::is_dry_run() {
167        let labels_str = args.labels.as_ref().map(|l| l.join(",")).unwrap_or_default();
168        if json {
169            let output = serde_json::json!({
170                "dry_run": true,
171                "action": "create_issue",
172                "title": args.title,
173                "issue_type": issue_type,
174                "priority": priority,
175                "labels": labels_str,
176            });
177            println!("{output}");
178        } else {
179            println!("Would create issue: {} [{}, priority={}]", args.title, issue_type, priority);
180            if !labels_str.is_empty() {
181                println!("  Labels: {labels_str}");
182            }
183        }
184        return Ok(());
185    }
186
187    let mut storage = SqliteStorage::open(&db_path)?;
188    let project_path = resolve_project_path(&storage, None)?;
189
190    // Generate IDs
191    let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
192    let short_id = generate_short_id();
193
194    storage.create_issue(
195        &id,
196        Some(&short_id),
197        &project_path,
198        &args.title,
199        args.description.as_deref(),
200        args.details.as_deref(),
201        Some(&issue_type),
202        Some(priority),
203        args.plan_id.as_deref(),
204        &actor,
205    )?;
206
207    // Set parent via parent-child dependency if provided
208    if let Some(ref parent) = args.parent {
209        storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
210    }
211
212    // Add labels if provided (already Vec from clap value_delimiter)
213    if let Some(ref labels) = args.labels {
214        if !labels.is_empty() {
215            storage.add_issue_labels(&id, labels, &actor)?;
216        }
217    }
218
219    if crate::is_silent() {
220        println!("{short_id}");
221        return Ok(());
222    }
223
224    if json {
225        let output = IssueCreateOutput {
226            id,
227            short_id: Some(short_id),
228            title: args.title.clone(),
229            status: "open".to_string(),
230            priority,
231            issue_type: issue_type.clone(),
232        };
233        println!("{}", serde_json::to_string(&output)?);
234    } else {
235        println!("Created issue: {} [{}]", args.title, short_id);
236        println!("  Type: {issue_type}");
237        println!("  Priority: {priority}");
238    }
239
240    Ok(())
241}
242
243/// Create issues from a JSONL file (one JSON object per line).
244fn create_from_file(
245    file_path: &PathBuf,
246    db_path: Option<&PathBuf>,
247    actor: Option<&str>,
248    json: bool,
249) -> Result<()> {
250    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
251        .ok_or(Error::NotInitialized)?;
252
253    if !db_path.exists() {
254        return Err(Error::NotInitialized);
255    }
256
257    let file = std::fs::File::open(file_path)
258        .map_err(|e| Error::Other(format!("Could not open file {}: {e}", file_path.display())))?;
259
260    let reader = std::io::BufReader::new(file);
261    let mut issues: Vec<BatchIssue> = Vec::new();
262
263    for (line_num, line) in reader.lines().enumerate() {
264        let line = line.map_err(|e| Error::Other(format!("Read error at line {}: {e}", line_num + 1)))?;
265        let trimmed = line.trim();
266        if trimmed.is_empty() || trimmed.starts_with('#') {
267            continue; // Skip blank lines and comments
268        }
269        let issue: BatchIssue = serde_json::from_str(trimmed)
270            .map_err(|e| Error::Other(format!("Invalid JSON at line {}: {e}", line_num + 1)))?;
271        issues.push(issue);
272    }
273
274    if issues.is_empty() {
275        return Err(Error::Other("No issues found in file".to_string()));
276    }
277
278    // Dry-run: just preview
279    if crate::is_dry_run() {
280        if json {
281            let output = serde_json::json!({
282                "dry_run": true,
283                "action": "create_issues_from_file",
284                "file": file_path.display().to_string(),
285                "count": issues.len(),
286            });
287            println!("{output}");
288        } else {
289            println!("Would create {} issues from {}:", issues.len(), file_path.display());
290            for issue in &issues {
291                println!("  - {} [{}]", issue.title, issue.issue_type.as_deref().unwrap_or("task"));
292            }
293        }
294        return Ok(());
295    }
296
297    let mut storage = SqliteStorage::open(&db_path)?;
298    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
299    let project_path = resolve_project_path(&storage, None)?;
300
301    let mut results: Vec<BatchIssueResult> = Vec::with_capacity(issues.len());
302
303    for (index, issue) in issues.iter().enumerate() {
304        let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
305        let short_id = generate_short_id();
306
307        storage.create_issue(
308            &id,
309            Some(&short_id),
310            &project_path,
311            &issue.title,
312            issue.description.as_deref(),
313            issue.details.as_deref(),
314            issue.issue_type.as_deref(),
315            issue.priority,
316            issue.plan_id.as_deref(),
317            &actor,
318        )?;
319
320        if let Some(ref labels) = issue.labels {
321            if !labels.is_empty() {
322                storage.add_issue_labels(&id, labels, &actor)?;
323            }
324        }
325
326        results.push(BatchIssueResult {
327            id,
328            short_id: Some(short_id),
329            title: issue.title.clone(),
330            index,
331        });
332    }
333
334    if crate::is_silent() {
335        for r in &results {
336            println!("{}", r.short_id.as_deref().unwrap_or(&r.id));
337        }
338        return Ok(());
339    }
340
341    if json {
342        let output = serde_json::json!({
343            "issues": results,
344            "count": results.len(),
345        });
346        println!("{}", serde_json::to_string(&output)?);
347    } else {
348        println!("Created {} issues from {}:", results.len(), file_path.display());
349        for r in &results {
350            let sid = r.short_id.as_deref().unwrap_or(&r.id[..8]);
351            println!("  [{}] {}", sid, r.title);
352        }
353    }
354
355    Ok(())
356}
357
358fn list(args: &IssueListArgs, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
359    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
360        .ok_or(Error::NotInitialized)?;
361
362    if !db_path.exists() {
363        return Err(Error::NotInitialized);
364    }
365
366    let storage = SqliteStorage::open(&db_path)?;
367
368    // Handle single issue lookup by ID
369    if let Some(ref id) = args.id {
370        let project_path = resolve_project_path(&storage, None).ok();
371        let issue = storage
372            .get_issue(id, project_path.as_deref())?
373            .ok_or_else(|| {
374                let all_ids = storage.get_all_issue_short_ids().unwrap_or_default();
375                let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
376                if similar.is_empty() {
377                    Error::IssueNotFound { id: id.to_string() }
378                } else {
379                    Error::IssueNotFoundSimilar {
380                        id: id.to_string(),
381                        similar,
382                    }
383                }
384            })?;
385        if json {
386            let output = IssueListOutput {
387                count: 1,
388                issues: vec![issue],
389            };
390            println!("{}", serde_json::to_string(&output)?);
391        } else {
392            print_issue_list(&[issue], Some(&storage));
393        }
394        return Ok(());
395    }
396
397    // Determine project filter
398    let project_path = if args.all_projects {
399        None
400    } else {
401        Some(resolve_project_path(&storage, None)?)
402    };
403
404    // Normalize status filter via synonym lookup (e.g., "done" → "closed")
405    let normalized_status = if args.status == "all" {
406        "all".to_string()
407    } else {
408        crate::validate::normalize_status(&args.status).unwrap_or_else(|_| args.status.clone())
409    };
410    let status = Some(normalized_status.as_str());
411
412    // Get base results from storage (fetch extra for post-filtering)
413    #[allow(clippy::cast_possible_truncation)]
414    let fetch_limit = (args.limit * 10).min(1000) as u32;
415
416    let issues = if let Some(ref path) = project_path {
417        storage.list_issues(path, status, args.issue_type.as_deref(), Some(fetch_limit))?
418    } else {
419        // For all_projects, we need to query without project filter
420        // Storage doesn't support this directly, so we need a workaround
421        // For now, get from storage with a higher limit
422        storage.list_all_issues(status, args.issue_type.as_deref(), Some(fetch_limit))?
423    };
424
425    // Apply post-filters
426    let now = std::time::SystemTime::now()
427        .duration_since(std::time::UNIX_EPOCH)
428        .map(|d| d.as_secs() as i64)
429        .unwrap_or(0);
430
431    // Pre-fetch child IDs if filtering by parent
432    let child_ids = if let Some(ref parent) = args.parent {
433        Some(storage.get_child_issue_ids(parent)?)
434    } else {
435        None
436    };
437
438    let issues: Vec<_> = issues
439        .into_iter()
440        // Filter by search
441        .filter(|i| {
442            if let Some(ref search) = args.search {
443                let s = search.to_lowercase();
444                i.title.to_lowercase().contains(&s)
445                    || i.description
446                        .as_ref()
447                        .map(|d| d.to_lowercase().contains(&s))
448                        .unwrap_or(false)
449            } else {
450                true
451            }
452        })
453        // Filter by exact priority
454        .filter(|i| args.priority.map_or(true, |p| i.priority == p))
455        // Filter by priority range
456        .filter(|i| args.priority_min.map_or(true, |p| i.priority >= p))
457        .filter(|i| args.priority_max.map_or(true, |p| i.priority <= p))
458        // Filter by parent
459        .filter(|i| {
460            if let Some(ref child_set) = child_ids {
461                // Only include issues that are children of the specified parent
462                child_set.contains(&i.id)
463            } else {
464                true
465            }
466        })
467        // Filter by plan
468        .filter(|i| {
469            if let Some(ref plan) = args.plan {
470                i.plan_id.as_ref().map_or(false, |p| p == plan)
471            } else {
472                true
473            }
474        })
475        // Filter by assignee
476        .filter(|i| {
477            if let Some(ref assignee) = args.assignee {
478                i.assigned_to_agent
479                    .as_ref()
480                    .map_or(false, |a| a == assignee)
481            } else {
482                true
483            }
484        })
485        // Filter by created time
486        .filter(|i| {
487            if let Some(days) = args.created_days {
488                let cutoff = now - (days * 24 * 60 * 60);
489                i.created_at >= cutoff
490            } else {
491                true
492            }
493        })
494        .filter(|i| {
495            if let Some(hours) = args.created_hours {
496                let cutoff = now - (hours * 60 * 60);
497                i.created_at >= cutoff
498            } else {
499                true
500            }
501        })
502        // Filter by updated time
503        .filter(|i| {
504            if let Some(days) = args.updated_days {
505                let cutoff = now - (days * 24 * 60 * 60);
506                i.updated_at >= cutoff
507            } else {
508                true
509            }
510        })
511        .filter(|i| {
512            if let Some(hours) = args.updated_hours {
513                let cutoff = now - (hours * 60 * 60);
514                i.updated_at >= cutoff
515            } else {
516                true
517            }
518        })
519        .collect();
520
521    // Apply label filtering
522    let issues: Vec<_> = if args.labels.is_some() || args.labels_any.is_some() {
523        issues
524            .into_iter()
525            .filter(|i| {
526                let issue_labels = storage.get_issue_labels(&i.id).unwrap_or_default();
527
528                // Check --labels (all must match)
529                let all_match = args.labels.as_ref().map_or(true, |required| {
530                    required.iter().all(|l| issue_labels.contains(l))
531                });
532
533                // Check --labels-any (any must match)
534                let any_match = args.labels_any.as_ref().map_or(true, |required| {
535                    required.iter().any(|l| issue_labels.contains(l))
536                });
537
538                all_match && any_match
539            })
540            .collect()
541    } else {
542        issues
543    };
544
545    // Apply has_deps/no_deps filtering
546    let issues: Vec<_> = if args.has_deps || args.no_deps {
547        issues
548            .into_iter()
549            .filter(|i| {
550                let has_dependencies = storage.issue_has_dependencies(&i.id).unwrap_or(false);
551                if args.has_deps {
552                    has_dependencies
553                } else {
554                    !has_dependencies
555                }
556            })
557            .collect()
558    } else {
559        issues
560    };
561
562    // Apply has_subtasks/no_subtasks filtering
563    let issues: Vec<_> = if args.has_subtasks || args.no_subtasks {
564        issues
565            .into_iter()
566            .filter(|i| {
567                let has_subtasks = storage.issue_has_subtasks(&i.id).unwrap_or(false);
568                if args.has_subtasks {
569                    has_subtasks
570                } else {
571                    !has_subtasks
572                }
573            })
574            .collect()
575    } else {
576        issues
577    };
578
579    // Apply sorting
580    let mut issues = issues;
581    match args.sort.as_str() {
582        "priority" => issues.sort_by(|a, b| {
583            if args.order == "asc" {
584                a.priority.cmp(&b.priority)
585            } else {
586                b.priority.cmp(&a.priority)
587            }
588        }),
589        "updatedAt" => issues.sort_by(|a, b| {
590            if args.order == "asc" {
591                a.updated_at.cmp(&b.updated_at)
592            } else {
593                b.updated_at.cmp(&a.updated_at)
594            }
595        }),
596        _ => {
597            // Default: createdAt
598            issues.sort_by(|a, b| {
599                if args.order == "asc" {
600                    a.created_at.cmp(&b.created_at)
601                } else {
602                    b.created_at.cmp(&a.created_at)
603                }
604            });
605        }
606    }
607
608    // Apply limit
609    issues.truncate(args.limit);
610
611    if crate::is_csv() {
612        println!("id,title,status,priority,type,assigned_to");
613        for issue in &issues {
614            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
615            let title = crate::csv_escape(&issue.title);
616            let assignee = issue.assigned_to_agent.as_deref().unwrap_or("");
617            println!("{},{},{},{},{},{}", short_id, title, issue.status, issue.priority, issue.issue_type, assignee);
618        }
619    } else if json {
620        let output = IssueListOutput {
621            count: issues.len(),
622            issues,
623        };
624        println!("{}", serde_json::to_string(&output)?);
625    } else if issues.is_empty() {
626        println!("No issues found.");
627    } else {
628        print_issue_list(&issues, Some(&storage));
629    }
630
631    Ok(())
632}
633
634/// Print formatted issue list to stdout.
635fn print_issue_list(issues: &[crate::storage::Issue], storage: Option<&SqliteStorage>) {
636    println!("Issues ({} found):", issues.len());
637    println!();
638    for issue in issues {
639        let status_icon = match issue.status.as_str() {
640            "open" => "○",
641            "in_progress" => "●",
642            "blocked" => "⊘",
643            "closed" => "✓",
644            "deferred" => "◌",
645            _ => "?",
646        };
647        let priority_str = match issue.priority {
648            4 => "!!",
649            3 => "! ",
650            2 => "  ",
651            1 => "- ",
652            0 => "--",
653            _ => "  ",
654        };
655        let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
656
657        // Show epic progress inline if available
658        let progress_str = if issue.issue_type == "epic" {
659            storage.and_then(|s| s.get_epic_progress(&issue.id).ok())
660                .filter(|p| p.total > 0)
661                .map(|p| {
662                    let pct = (p.closed as f64 / p.total as f64 * 100.0) as u32;
663                    format!(" {}/{} ({pct}%)", p.closed, p.total)
664                })
665                .unwrap_or_default()
666        } else {
667            String::new()
668        };
669
670        println!(
671            "{} [{}] {} {} ({}){progress_str}",
672            status_icon, short_id, priority_str, issue.title, issue.issue_type
673        );
674        if let Some(ref desc) = issue.description {
675            let truncated = if desc.len() > 60 {
676                format!("{}...", &desc[..60])
677            } else {
678                desc.clone()
679            };
680            println!("        {truncated}");
681        }
682    }
683}
684
685fn show(id: &str, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
686    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
687        .ok_or(Error::NotInitialized)?;
688
689    if !db_path.exists() {
690        return Err(Error::NotInitialized);
691    }
692
693    let storage = SqliteStorage::open(&db_path)?;
694    let project_path = resolve_project_path(&storage, None).ok();
695
696    let issue = storage
697        .get_issue(id, project_path.as_deref())?
698        .ok_or_else(|| {
699            let all_ids = storage.get_all_issue_short_ids().unwrap_or_default();
700            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
701            if similar.is_empty() {
702                Error::IssueNotFound { id: id.to_string() }
703            } else {
704                Error::IssueNotFoundSimilar {
705                    id: id.to_string(),
706                    similar,
707                }
708            }
709        })?;
710
711    // Check for epic progress
712    let progress = if issue.issue_type == "epic" {
713        storage.get_epic_progress(&issue.id).ok()
714            .filter(|p| p.total > 0)
715    } else {
716        None
717    };
718
719    // Check for close_reason
720    let close_reason = if issue.status == "closed" {
721        storage.get_close_reason(&issue.id).ok().flatten()
722    } else {
723        None
724    };
725
726    if json {
727        let mut value = serde_json::to_value(&issue)?;
728        if let Some(ref p) = progress {
729            value["progress"] = serde_json::to_value(p)?;
730        }
731        if let Some(ref reason) = close_reason {
732            value["close_reason"] = serde_json::Value::String(reason.clone());
733        }
734        println!("{}", serde_json::to_string(&value)?);
735    } else {
736        let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
737        println!("[{}] {}", short_id, issue.title);
738        println!();
739        println!("Status:   {}", issue.status);
740        println!("Type:     {}", issue.issue_type);
741        println!("Priority: {}", issue.priority);
742        if let Some(ref desc) = issue.description {
743            println!();
744            println!("Description:");
745            println!("{desc}");
746        }
747        if let Some(ref details) = issue.details {
748            println!();
749            println!("Details:");
750            println!("{details}");
751        }
752        if let Some(ref agent) = issue.assigned_to_agent {
753            println!();
754            println!("Assigned to: {agent}");
755        }
756        if let Some(ref reason) = close_reason {
757            println!();
758            println!("Close reason: {reason}");
759        }
760        if let Some(ref p) = progress {
761            let pct = if p.total > 0 {
762                (p.closed as f64 / p.total as f64 * 100.0) as u32
763            } else {
764                0
765            };
766            println!();
767            println!("Progress: {}/{} tasks ({pct}%)", p.closed, p.total);
768            if p.closed > 0 { println!("  Closed:      {}", p.closed); }
769            if p.in_progress > 0 { println!("  In progress: {}", p.in_progress); }
770            if p.open > 0 { println!("  Open:        {}", p.open); }
771            if p.blocked > 0 { println!("  Blocked:     {}", p.blocked); }
772            if p.deferred > 0 { println!("  Deferred:    {}", p.deferred); }
773        }
774    }
775
776    Ok(())
777}
778
779fn update(
780    args: &IssueUpdateArgs,
781    db_path: Option<&PathBuf>,
782    actor: Option<&str>,
783    json: bool,
784) -> Result<()> {
785    if crate::is_dry_run() {
786        if json {
787            let output = serde_json::json!({
788                "dry_run": true,
789                "action": "update_issue",
790                "id": args.id,
791            });
792            println!("{output}");
793        } else {
794            println!("Would update issue: {}", args.id);
795        }
796        return Ok(());
797    }
798
799    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
800        .ok_or(Error::NotInitialized)?;
801
802    if !db_path.exists() {
803        return Err(Error::NotInitialized);
804    }
805
806    let mut storage = SqliteStorage::open(&db_path)?;
807    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
808
809    // Normalize type if provided
810    let normalized_type = args.issue_type.as_ref().map(|t| {
811        crate::validate::normalize_type(t).unwrap_or_else(|_| t.clone())
812    });
813
814    // Normalize priority if provided
815    let normalized_priority = args.priority.map(|p| {
816        crate::validate::normalize_priority(&p.to_string()).unwrap_or(p)
817    });
818
819    // Check if any non-status fields are being updated
820    let has_field_updates = args.title.is_some()
821        || args.description.is_some()
822        || args.details.is_some()
823        || normalized_priority.is_some()
824        || normalized_type.is_some()
825        || args.plan.is_some()
826        || args.parent.is_some();
827
828    // Update fields if any are provided
829    if has_field_updates {
830        storage.update_issue(
831            &args.id,
832            args.title.as_deref(),
833            args.description.as_deref(),
834            args.details.as_deref(),
835            normalized_priority,
836            normalized_type.as_deref(),
837            args.plan.as_deref(),
838            args.parent.as_deref(),
839            &actor,
840        )?;
841    }
842
843    // Normalize and update status if provided
844    if let Some(ref status) = args.status {
845        let normalized = crate::validate::normalize_status(status)
846            .unwrap_or_else(|_| status.clone());
847        storage.update_issue_status(&args.id, &normalized, &actor)?;
848    }
849
850    if json {
851        let output = serde_json::json!({
852            "id": args.id,
853            "updated": true
854        });
855        println!("{output}");
856    } else {
857        println!("Updated issue: {}", args.id);
858    }
859
860    Ok(())
861}
862
863fn complete(ids: &[String], reason: Option<&str>, db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
864    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
865        .ok_or(Error::NotInitialized)?;
866
867    if !db_path.exists() {
868        return Err(Error::NotInitialized);
869    }
870
871    if crate::is_dry_run() {
872        for id in ids {
873            println!("Would complete issue: {id}");
874        }
875        return Ok(());
876    }
877
878    let mut storage = SqliteStorage::open(&db_path)?;
879    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
880
881    let mut results = Vec::new();
882    for id in ids {
883        storage.update_issue_status(id, "closed", &actor)?;
884        if let Some(reason) = reason {
885            storage.set_close_reason(id, reason, &actor)?;
886        }
887        results.push(id.as_str());
888    }
889
890    if crate::is_silent() {
891        for id in &results {
892            println!("{id}");
893        }
894    } else if json {
895        let mut output = serde_json::json!({
896            "ids": results,
897            "status": "closed",
898            "count": results.len()
899        });
900        if let Some(reason) = reason {
901            output["close_reason"] = serde_json::Value::String(reason.to_string());
902        }
903        println!("{output}");
904    } else {
905        for id in &results {
906            println!("Completed issue: {id}");
907        }
908        if let Some(reason) = reason {
909            println!("  Reason: {reason}");
910        }
911    }
912
913    Ok(())
914}
915
916fn claim(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
917    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
918        .ok_or(Error::NotInitialized)?;
919
920    if !db_path.exists() {
921        return Err(Error::NotInitialized);
922    }
923
924    if crate::is_dry_run() {
925        for id in ids {
926            println!("Would claim issue: {id}");
927        }
928        return Ok(());
929    }
930
931    let mut storage = SqliteStorage::open(&db_path)?;
932    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
933
934    let mut results = Vec::new();
935    for id in ids {
936        storage.claim_issue(id, &actor)?;
937        results.push(id.as_str());
938    }
939
940    if crate::is_silent() {
941        for id in &results {
942            println!("{id}");
943        }
944    } else if json {
945        let output = serde_json::json!({
946            "ids": results,
947            "status": "in_progress",
948            "assigned_to": actor,
949            "count": results.len()
950        });
951        println!("{output}");
952    } else {
953        for id in &results {
954            println!("Claimed issue: {id}");
955        }
956    }
957
958    Ok(())
959}
960
961fn release(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
962    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
963        .ok_or(Error::NotInitialized)?;
964
965    if !db_path.exists() {
966        return Err(Error::NotInitialized);
967    }
968
969    if crate::is_dry_run() {
970        for id in ids {
971            println!("Would release issue: {id}");
972        }
973        return Ok(());
974    }
975
976    let mut storage = SqliteStorage::open(&db_path)?;
977    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
978
979    let mut results = Vec::new();
980    for id in ids {
981        storage.release_issue(id, &actor)?;
982        results.push(id.as_str());
983    }
984
985    if crate::is_silent() {
986        for id in &results {
987            println!("{id}");
988        }
989    } else if json {
990        let output = serde_json::json!({
991            "ids": results,
992            "status": "open",
993            "count": results.len()
994        });
995        println!("{output}");
996    } else {
997        for id in &results {
998            println!("Released issue: {id}");
999        }
1000    }
1001
1002    Ok(())
1003}
1004
1005fn delete(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
1006    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1007        .ok_or(Error::NotInitialized)?;
1008
1009    if !db_path.exists() {
1010        return Err(Error::NotInitialized);
1011    }
1012
1013    if crate::is_dry_run() {
1014        for id in ids {
1015            println!("Would delete issue: {id}");
1016        }
1017        return Ok(());
1018    }
1019
1020    let mut storage = SqliteStorage::open(&db_path)?;
1021    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1022
1023    let mut results = Vec::new();
1024    for id in ids {
1025        storage.delete_issue(id, &actor)?;
1026        results.push(id.as_str());
1027    }
1028
1029    if crate::is_silent() {
1030        for id in &results {
1031            println!("{id}");
1032        }
1033    } else if json {
1034        let output = serde_json::json!({
1035            "ids": results,
1036            "deleted": true,
1037            "count": results.len()
1038        });
1039        println!("{output}");
1040    } else {
1041        for id in &results {
1042            println!("Deleted issue: {id}");
1043        }
1044    }
1045
1046    Ok(())
1047}
1048
1049/// Generate a short ID (4 hex chars).
1050fn generate_short_id() -> String {
1051    use std::time::{SystemTime, UNIX_EPOCH};
1052    let now = SystemTime::now()
1053        .duration_since(UNIX_EPOCH)
1054        .unwrap()
1055        .as_millis();
1056    format!("{:04x}", (now & 0xFFFF) as u16)
1057}
1058
1059fn label(
1060    command: &IssueLabelCommands,
1061    db_path: Option<&PathBuf>,
1062    actor: Option<&str>,
1063    json: bool,
1064) -> Result<()> {
1065    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1066        .ok_or(Error::NotInitialized)?;
1067
1068    if !db_path.exists() {
1069        return Err(Error::NotInitialized);
1070    }
1071
1072    let mut storage = SqliteStorage::open(&db_path)?;
1073    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1074
1075    match command {
1076        IssueLabelCommands::Add { id, labels } => {
1077            storage.add_issue_labels(id, labels, &actor)?;
1078
1079            if json {
1080                let output = serde_json::json!({
1081                    "id": id,
1082                    "action": "add",
1083                    "labels": labels
1084                });
1085                println!("{output}");
1086            } else {
1087                println!("Added labels to {}: {}", id, labels.join(", "));
1088            }
1089        }
1090        IssueLabelCommands::Remove { id, labels } => {
1091            storage.remove_issue_labels(id, labels, &actor)?;
1092
1093            if json {
1094                let output = serde_json::json!({
1095                    "id": id,
1096                    "action": "remove",
1097                    "labels": labels
1098                });
1099                println!("{output}");
1100            } else {
1101                println!("Removed labels from {}: {}", id, labels.join(", "));
1102            }
1103        }
1104    }
1105
1106    Ok(())
1107}
1108
1109fn dep(
1110    command: &IssueDepCommands,
1111    db_path: Option<&PathBuf>,
1112    actor: Option<&str>,
1113    json: bool,
1114) -> Result<()> {
1115    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1116        .ok_or(Error::NotInitialized)?;
1117
1118    if !db_path.exists() {
1119        return Err(Error::NotInitialized);
1120    }
1121
1122    let mut storage = SqliteStorage::open(&db_path)?;
1123    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1124
1125    match command {
1126        IssueDepCommands::Add { id, depends_on, dep_type } => {
1127            storage.add_issue_dependency(id, depends_on, dep_type, &actor)?;
1128
1129            if json {
1130                let output = serde_json::json!({
1131                    "issue_id": id,
1132                    "depends_on_id": depends_on,
1133                    "dependency_type": dep_type
1134                });
1135                println!("{output}");
1136            } else {
1137                println!("Added dependency: {} depends on {} ({})", id, depends_on, dep_type);
1138            }
1139        }
1140        IssueDepCommands::Remove { id, depends_on } => {
1141            storage.remove_issue_dependency(id, depends_on, &actor)?;
1142
1143            if json {
1144                let output = serde_json::json!({
1145                    "issue_id": id,
1146                    "depends_on_id": depends_on,
1147                    "removed": true
1148                });
1149                println!("{output}");
1150            } else {
1151                println!("Removed dependency: {} no longer depends on {}", id, depends_on);
1152            }
1153        }
1154        IssueDepCommands::Tree { id } => {
1155            return dep_tree(id.as_deref(), Some(&db_path), json);
1156        }
1157    }
1158
1159    Ok(())
1160}
1161
1162fn clone_issue(
1163    id: &str,
1164    new_title: Option<&str>,
1165    db_path: Option<&PathBuf>,
1166    actor: Option<&str>,
1167    json: bool,
1168) -> Result<()> {
1169    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1170        .ok_or(Error::NotInitialized)?;
1171
1172    if !db_path.exists() {
1173        return Err(Error::NotInitialized);
1174    }
1175
1176    let mut storage = SqliteStorage::open(&db_path)?;
1177    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1178
1179    let cloned = storage.clone_issue(id, new_title, &actor)?;
1180
1181    if json {
1182        println!("{}", serde_json::to_string(&cloned)?);
1183    } else {
1184        let short_id = cloned.short_id.as_deref().unwrap_or(&cloned.id[..8]);
1185        println!("Cloned issue {} to: {} [{}]", id, cloned.title, short_id);
1186    }
1187
1188    Ok(())
1189}
1190
1191fn duplicate(
1192    id: &str,
1193    duplicate_of: &str,
1194    db_path: Option<&PathBuf>,
1195    actor: Option<&str>,
1196    json: bool,
1197) -> Result<()> {
1198    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1199        .ok_or(Error::NotInitialized)?;
1200
1201    if !db_path.exists() {
1202        return Err(Error::NotInitialized);
1203    }
1204
1205    let mut storage = SqliteStorage::open(&db_path)?;
1206    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1207
1208    storage.mark_issue_duplicate(id, duplicate_of, &actor)?;
1209
1210    if json {
1211        let output = serde_json::json!({
1212            "id": id,
1213            "duplicate_of": duplicate_of,
1214            "status": "closed"
1215        });
1216        println!("{output}");
1217    } else {
1218        println!("Marked {} as duplicate of {} (closed)", id, duplicate_of);
1219    }
1220
1221    Ok(())
1222}
1223
1224fn ready(limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1225    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1226        .ok_or(Error::NotInitialized)?;
1227
1228    if !db_path.exists() {
1229        return Err(Error::NotInitialized);
1230    }
1231
1232    let storage = SqliteStorage::open(&db_path)?;
1233    let project_path = resolve_project_path(&storage, None)?;
1234
1235    #[allow(clippy::cast_possible_truncation)]
1236    let issues = storage.get_ready_issues(&project_path, limit as u32)?;
1237
1238    if json {
1239        let output = IssueListOutput {
1240            count: issues.len(),
1241            issues,
1242        };
1243        println!("{}", serde_json::to_string(&output)?);
1244    } else if issues.is_empty() {
1245        println!("No issues ready to work on.");
1246    } else {
1247        println!("Ready issues ({} found):", issues.len());
1248        println!();
1249        for issue in &issues {
1250            let priority_str = match issue.priority {
1251                4 => "!!",
1252                3 => "! ",
1253                2 => "  ",
1254                1 => "- ",
1255                0 => "--",
1256                _ => "  ",
1257            };
1258            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1259            println!(
1260                "○ [{}] {} {} ({})",
1261                short_id, priority_str, issue.title, issue.issue_type
1262            );
1263        }
1264    }
1265
1266    Ok(())
1267}
1268
1269fn next_block(
1270    count: usize,
1271    db_path: Option<&PathBuf>,
1272    actor: Option<&str>,
1273    json: bool,
1274) -> Result<()> {
1275    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1276        .ok_or(Error::NotInitialized)?;
1277
1278    if !db_path.exists() {
1279        return Err(Error::NotInitialized);
1280    }
1281
1282    let mut storage = SqliteStorage::open(&db_path)?;
1283    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1284    let project_path = resolve_project_path(&storage, None)?;
1285
1286    #[allow(clippy::cast_possible_truncation)]
1287    let issues = storage.get_next_issue_block(&project_path, count as u32, &actor)?;
1288
1289    if json {
1290        let output = IssueListOutput {
1291            count: issues.len(),
1292            issues,
1293        };
1294        println!("{}", serde_json::to_string(&output)?);
1295    } else if issues.is_empty() {
1296        println!("No issues available to claim.");
1297    } else {
1298        println!("Claimed {} issues:", issues.len());
1299        println!();
1300        for issue in &issues {
1301            let priority_str = match issue.priority {
1302                4 => "!!",
1303                3 => "! ",
1304                2 => "  ",
1305                1 => "- ",
1306                0 => "--",
1307                _ => "  ",
1308            };
1309            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1310            println!(
1311                "● [{}] {} {} ({})",
1312                short_id, priority_str, issue.title, issue.issue_type
1313            );
1314        }
1315    }
1316
1317    Ok(())
1318}
1319
1320/// Create multiple issues at once with dependencies.
1321fn batch(
1322    json_input: &str,
1323    db_path: Option<&PathBuf>,
1324    actor: Option<&str>,
1325    json: bool,
1326) -> Result<()> {
1327    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1328        .ok_or(Error::NotInitialized)?;
1329
1330    if !db_path.exists() {
1331        return Err(Error::NotInitialized);
1332    }
1333
1334    let mut storage = SqliteStorage::open(&db_path)?;
1335    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1336    let project_path = resolve_project_path(&storage, None)?;
1337
1338    // Parse the JSON input
1339    let input: BatchInput = serde_json::from_str(json_input)
1340        .map_err(|e| Error::Other(format!("Invalid JSON input: {e}")))?;
1341
1342    // Track created issue IDs by index for resolving $N references
1343    let mut created_ids: Vec<String> = Vec::with_capacity(input.issues.len());
1344    let mut results: Vec<BatchIssueResult> = Vec::with_capacity(input.issues.len());
1345
1346    // Create issues in order
1347    for (index, issue) in input.issues.iter().enumerate() {
1348        let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
1349        let short_id = generate_short_id();
1350
1351        // Resolve parent_id: if it starts with "$", look up created ID by index
1352        let resolved_parent_id = issue.parent_id.as_ref().and_then(|pid| {
1353            if let Some(idx_str) = pid.strip_prefix('$') {
1354                if let Ok(idx) = idx_str.parse::<usize>() {
1355                    created_ids.get(idx).cloned()
1356                } else {
1357                    Some(pid.clone())
1358                }
1359            } else {
1360                Some(pid.clone())
1361            }
1362        });
1363
1364        // Use issue-level plan_id, or fall back to batch-level plan_id
1365        let plan_id = issue.plan_id.as_ref().or(input.plan_id.as_ref());
1366
1367        storage.create_issue(
1368            &id,
1369            Some(&short_id),
1370            &project_path,
1371            &issue.title,
1372            issue.description.as_deref(),
1373            issue.details.as_deref(),
1374            issue.issue_type.as_deref(),
1375            issue.priority,
1376            plan_id.map(String::as_str),
1377            &actor,
1378        )?;
1379
1380        // Set parent via parent-child dependency if resolved
1381        if let Some(ref parent) = resolved_parent_id {
1382            storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
1383        }
1384
1385        // Add labels if provided
1386        if let Some(ref labels) = issue.labels {
1387            if !labels.is_empty() {
1388                storage.add_issue_labels(&id, labels, &actor)?;
1389            }
1390        }
1391
1392        created_ids.push(id.clone());
1393        results.push(BatchIssueResult {
1394            id,
1395            short_id: Some(short_id),
1396            title: issue.title.clone(),
1397            index,
1398        });
1399    }
1400
1401    // Create dependencies
1402    let mut dep_results: Vec<BatchDepResult> = Vec::new();
1403    if let Some(deps) = input.dependencies {
1404        for dep in deps {
1405            if dep.issue_index >= created_ids.len() || dep.depends_on_index >= created_ids.len() {
1406                return Err(Error::Other(format!(
1407                    "Dependency index out of range: {} -> {}",
1408                    dep.issue_index, dep.depends_on_index
1409                )));
1410            }
1411
1412            let issue_id = &created_ids[dep.issue_index];
1413            let depends_on_id = &created_ids[dep.depends_on_index];
1414            let dep_type = dep.dependency_type.as_deref().unwrap_or("blocks");
1415
1416            storage.add_issue_dependency(issue_id, depends_on_id, dep_type, &actor)?;
1417
1418            dep_results.push(BatchDepResult {
1419                issue_id: issue_id.clone(),
1420                depends_on_id: depends_on_id.clone(),
1421                dependency_type: dep_type.to_string(),
1422            });
1423        }
1424    }
1425
1426    let output = BatchOutput {
1427        issues: results,
1428        dependencies: dep_results,
1429    };
1430
1431    if json {
1432        println!("{}", serde_json::to_string(&output)?);
1433    } else {
1434        println!("Created {} issues:", output.issues.len());
1435        for result in &output.issues {
1436            let short_id = result.short_id.as_deref().unwrap_or(&result.id[..8]);
1437            println!("  [{}] {}", short_id, result.title);
1438        }
1439        if !output.dependencies.is_empty() {
1440            println!("\nCreated {} dependencies:", output.dependencies.len());
1441            for dep in &output.dependencies {
1442                println!("  {} -> {} ({})", dep.issue_id, dep.depends_on_id, dep.dependency_type);
1443            }
1444        }
1445    }
1446
1447    Ok(())
1448}
1449
1450fn count(group_by: &str, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1451    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1452        .ok_or(Error::NotInitialized)?;
1453
1454    if !db_path.exists() {
1455        return Err(Error::NotInitialized);
1456    }
1457
1458    let storage = SqliteStorage::open(&db_path)?;
1459    let project_path = resolve_project_path(&storage, None)?;
1460
1461    let groups = storage.count_issues_grouped(&project_path, group_by)?;
1462    let total: i64 = groups.iter().map(|(_, c)| c).sum();
1463
1464    if crate::is_csv() {
1465        println!("group,count");
1466        for (key, count) in &groups {
1467            println!("{},{count}", crate::csv_escape(key));
1468        }
1469    } else if json {
1470        let output = serde_json::json!({
1471            "groups": groups.iter().map(|(k, c)| {
1472                serde_json::json!({"key": k, "count": c})
1473            }).collect::<Vec<_>>(),
1474            "total": total,
1475            "group_by": group_by
1476        });
1477        println!("{output}");
1478    } else if groups.is_empty() {
1479        println!("No issues found.");
1480    } else {
1481        println!("Issues by {group_by}:");
1482        let max_key_len = groups.iter().map(|(k, _)| k.len()).max().unwrap_or(10);
1483        for (key, count) in &groups {
1484            println!("  {:<width$}  {count}", key, width = max_key_len);
1485        }
1486        println!("  {}", "─".repeat(max_key_len + 6));
1487        println!("  {:<width$}  {total}", "Total", width = max_key_len);
1488    }
1489
1490    Ok(())
1491}
1492
1493fn stale(days: u64, limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1494    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1495        .ok_or(Error::NotInitialized)?;
1496
1497    if !db_path.exists() {
1498        return Err(Error::NotInitialized);
1499    }
1500
1501    let storage = SqliteStorage::open(&db_path)?;
1502    let project_path = resolve_project_path(&storage, None)?;
1503
1504    #[allow(clippy::cast_possible_truncation)]
1505    let issues = storage.get_stale_issues(&project_path, days, limit as u32)?;
1506    let now_ms = chrono::Utc::now().timestamp_millis();
1507
1508    if crate::is_csv() {
1509        println!("id,title,status,priority,type,stale_days");
1510        for issue in &issues {
1511            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1512            let title = crate::csv_escape(&issue.title);
1513            let stale_d = (now_ms - issue.updated_at) / (24 * 60 * 60 * 1000);
1514            println!("{short_id},{title},{},{},{},{stale_d}", issue.status, issue.priority, issue.issue_type);
1515        }
1516    } else if json {
1517        let enriched: Vec<_> = issues.iter().map(|i| {
1518            let stale_d = (now_ms - i.updated_at) / (24 * 60 * 60 * 1000);
1519            serde_json::json!({
1520                "issue": i,
1521                "stale_days": stale_d
1522            })
1523        }).collect();
1524        let output = serde_json::json!({
1525            "issues": enriched,
1526            "count": issues.len(),
1527            "threshold_days": days
1528        });
1529        println!("{}", serde_json::to_string(&output)?);
1530    } else if issues.is_empty() {
1531        println!("No stale issues (threshold: {days} days).");
1532    } else {
1533        println!("Stale issues ({} found, threshold: {days} days):", issues.len());
1534        println!();
1535        for issue in &issues {
1536            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1537            let stale_d = (now_ms - issue.updated_at) / (24 * 60 * 60 * 1000);
1538            let status_icon = match issue.status.as_str() {
1539                "open" => "○",
1540                "in_progress" => "●",
1541                "blocked" => "⊘",
1542                _ => "?",
1543            };
1544            println!(
1545                "{status_icon} [{}] {} ({}) — last updated {stale_d} days ago",
1546                short_id, issue.title, issue.issue_type
1547            );
1548        }
1549    }
1550
1551    Ok(())
1552}
1553
1554fn blocked(limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1555    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1556        .ok_or(Error::NotInitialized)?;
1557
1558    if !db_path.exists() {
1559        return Err(Error::NotInitialized);
1560    }
1561
1562    let storage = SqliteStorage::open(&db_path)?;
1563    let project_path = resolve_project_path(&storage, None)?;
1564
1565    #[allow(clippy::cast_possible_truncation)]
1566    let blocked_issues = storage.get_blocked_issues(&project_path, limit as u32)?;
1567
1568    if crate::is_csv() {
1569        println!("id,title,status,blocked_by_ids");
1570        for (issue, blockers) in &blocked_issues {
1571            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1572            let title = crate::csv_escape(&issue.title);
1573            let blocker_ids: Vec<&str> = blockers.iter()
1574                .map(|b| b.short_id.as_deref().unwrap_or(&b.id[..8]))
1575                .collect();
1576            println!("{short_id},{title},{},{}", issue.status, blocker_ids.join(";"));
1577        }
1578    } else if json {
1579        let entries: Vec<_> = blocked_issues.iter().map(|(issue, blockers)| {
1580            serde_json::json!({
1581                "issue": issue,
1582                "blocked_by": blockers
1583            })
1584        }).collect();
1585        let output = serde_json::json!({
1586            "blocked_issues": entries,
1587            "count": blocked_issues.len()
1588        });
1589        println!("{}", serde_json::to_string(&output)?);
1590    } else if blocked_issues.is_empty() {
1591        println!("No blocked issues.");
1592    } else {
1593        println!("Blocked issues ({} found):", blocked_issues.len());
1594        println!();
1595        for (issue, blockers) in &blocked_issues {
1596            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1597            println!("⊘ [{}] {} ({})", short_id, issue.title, issue.issue_type);
1598            for blocker in blockers {
1599                let b_short_id = blocker.short_id.as_deref().unwrap_or(&blocker.id[..8]);
1600                println!(
1601                    "    blocked by: [{}] {} [{}]",
1602                    b_short_id, blocker.title, blocker.status
1603                );
1604            }
1605        }
1606    }
1607
1608    Ok(())
1609}
1610
1611fn dep_tree(id: Option<&str>, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1612    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1613        .ok_or(Error::NotInitialized)?;
1614
1615    if !db_path.exists() {
1616        return Err(Error::NotInitialized);
1617    }
1618
1619    let storage = SqliteStorage::open(&db_path)?;
1620    let project_path = resolve_project_path(&storage, None)?;
1621
1622    if let Some(root_id) = id {
1623        // Show tree for a specific issue
1624        let tree = storage.get_dependency_tree(root_id)?;
1625        print_dep_tree(&tree, json)?;
1626    } else {
1627        // Show trees for all epics
1628        let epics = storage.get_epics(&project_path)?;
1629        if epics.is_empty() {
1630            if json {
1631                println!("{{\"trees\":[],\"count\":0}}");
1632            } else {
1633                println!("No epics found.");
1634            }
1635            return Ok(());
1636        }
1637
1638        if json {
1639            let mut trees = Vec::new();
1640            for epic in &epics {
1641                let tree = storage.get_dependency_tree(&epic.id)?;
1642                trees.push(tree_to_json(&tree));
1643            }
1644            let output = serde_json::json!({
1645                "trees": trees,
1646                "count": epics.len()
1647            });
1648            println!("{}", serde_json::to_string(&output)?);
1649        } else {
1650            for (i, epic) in epics.iter().enumerate() {
1651                if i > 0 {
1652                    println!();
1653                }
1654                let tree = storage.get_dependency_tree(&epic.id)?;
1655                print_ascii_tree(&tree);
1656            }
1657        }
1658    }
1659
1660    Ok(())
1661}
1662
1663fn print_dep_tree(tree: &[(crate::storage::Issue, i32)], json: bool) -> Result<()> {
1664    if json {
1665        let output = tree_to_json(tree);
1666        println!("{}", serde_json::to_string(&output)?);
1667    } else {
1668        print_ascii_tree(tree);
1669    }
1670    Ok(())
1671}
1672
1673fn tree_to_json(tree: &[(crate::storage::Issue, i32)]) -> serde_json::Value {
1674    if tree.is_empty() {
1675        return serde_json::json!(null);
1676    }
1677
1678    // Build nested structure from flat (issue, depth) list
1679    #[derive(serde::Serialize)]
1680    struct TreeNode {
1681        issue: serde_json::Value,
1682        children: Vec<TreeNode>,
1683    }
1684
1685    fn build_children(
1686        tree: &[(crate::storage::Issue, i32)],
1687        parent_idx: usize,
1688        parent_depth: i32,
1689    ) -> Vec<TreeNode> {
1690        let mut children = Vec::new();
1691        let mut i = parent_idx + 1;
1692        while i < tree.len() {
1693            let (ref issue, depth) = tree[i];
1694            if depth <= parent_depth {
1695                break;
1696            }
1697            if depth == parent_depth + 1 {
1698                let node = TreeNode {
1699                    issue: serde_json::to_value(issue).unwrap_or_default(),
1700                    children: build_children(tree, i, depth),
1701                };
1702                children.push(node);
1703            }
1704            i += 1;
1705        }
1706        children
1707    }
1708
1709    let (ref root, root_depth) = tree[0];
1710    let root_node = TreeNode {
1711        issue: serde_json::to_value(root).unwrap_or_default(),
1712        children: build_children(tree, 0, root_depth),
1713    };
1714
1715    serde_json::to_value(root_node).unwrap_or_default()
1716}
1717
1718fn print_ascii_tree(tree: &[(crate::storage::Issue, i32)]) {
1719    if tree.is_empty() {
1720        return;
1721    }
1722
1723    for (idx, (issue, depth)) in tree.iter().enumerate() {
1724        let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1725        let status_icon = match issue.status.as_str() {
1726            "open" => "○",
1727            "in_progress" => "●",
1728            "blocked" => "⊘",
1729            "closed" => "✓",
1730            "deferred" => "◌",
1731            _ => "?",
1732        };
1733
1734        if *depth == 0 {
1735            println!("{status_icon} {} [{}] {short_id}", issue.title, issue.issue_type);
1736        } else {
1737            // Find if this is the last child at this depth
1738            let is_last = !tree[idx + 1..].iter().any(|(_, d)| *d == *depth);
1739            let connector = if is_last { "└── " } else { "├── " };
1740            let indent: String = (1..*depth).map(|_| "│   ").collect();
1741            println!(
1742                "{indent}{connector}{status_icon} {} [{}] {short_id}",
1743                issue.title, issue.issue_type
1744            );
1745        }
1746    }
1747}