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    // Check for logged time
727    let time_total = storage.get_issue_time_total(&issue.id).unwrap_or(0.0);
728
729    if json {
730        let mut value = serde_json::to_value(&issue)?;
731        if let Some(ref p) = progress {
732            value["progress"] = serde_json::to_value(p)?;
733        }
734        if let Some(ref reason) = close_reason {
735            value["close_reason"] = serde_json::Value::String(reason.clone());
736        }
737        if time_total > 0.0 {
738            value["time_logged"] = serde_json::json!(time_total);
739        }
740        println!("{}", serde_json::to_string(&value)?);
741    } else {
742        let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
743        println!("[{}] {}", short_id, issue.title);
744        println!();
745        println!("Status:   {}", issue.status);
746        println!("Type:     {}", issue.issue_type);
747        println!("Priority: {}", issue.priority);
748        if let Some(ref desc) = issue.description {
749            println!();
750            println!("Description:");
751            println!("{desc}");
752        }
753        if let Some(ref details) = issue.details {
754            println!();
755            println!("Details:");
756            println!("{details}");
757        }
758        if let Some(ref agent) = issue.assigned_to_agent {
759            println!();
760            println!("Assigned to: {agent}");
761        }
762        if let Some(ref reason) = close_reason {
763            println!();
764            println!("Close reason: {reason}");
765        }
766        if time_total > 0.0 {
767            println!();
768            println!("Time logged: {time_total:.1}hrs");
769        }
770        if let Some(ref p) = progress {
771            let pct = if p.total > 0 {
772                (p.closed as f64 / p.total as f64 * 100.0) as u32
773            } else {
774                0
775            };
776            println!();
777            println!("Progress: {}/{} tasks ({pct}%)", p.closed, p.total);
778            if p.closed > 0 { println!("  Closed:      {}", p.closed); }
779            if p.in_progress > 0 { println!("  In progress: {}", p.in_progress); }
780            if p.open > 0 { println!("  Open:        {}", p.open); }
781            if p.blocked > 0 { println!("  Blocked:     {}", p.blocked); }
782            if p.deferred > 0 { println!("  Deferred:    {}", p.deferred); }
783        }
784    }
785
786    Ok(())
787}
788
789fn update(
790    args: &IssueUpdateArgs,
791    db_path: Option<&PathBuf>,
792    actor: Option<&str>,
793    json: bool,
794) -> Result<()> {
795    if crate::is_dry_run() {
796        if json {
797            let output = serde_json::json!({
798                "dry_run": true,
799                "action": "update_issue",
800                "id": args.id,
801            });
802            println!("{output}");
803        } else {
804            println!("Would update issue: {}", args.id);
805        }
806        return Ok(());
807    }
808
809    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
810        .ok_or(Error::NotInitialized)?;
811
812    if !db_path.exists() {
813        return Err(Error::NotInitialized);
814    }
815
816    let mut storage = SqliteStorage::open(&db_path)?;
817    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
818
819    // Normalize type if provided
820    let normalized_type = args.issue_type.as_ref().map(|t| {
821        crate::validate::normalize_type(t).unwrap_or_else(|_| t.clone())
822    });
823
824    // Normalize priority if provided
825    let normalized_priority = args.priority.map(|p| {
826        crate::validate::normalize_priority(&p.to_string()).unwrap_or(p)
827    });
828
829    // Check if any non-status fields are being updated
830    let has_field_updates = args.title.is_some()
831        || args.description.is_some()
832        || args.details.is_some()
833        || normalized_priority.is_some()
834        || normalized_type.is_some()
835        || args.plan.is_some()
836        || args.parent.is_some();
837
838    // Update fields if any are provided
839    if has_field_updates {
840        storage.update_issue(
841            &args.id,
842            args.title.as_deref(),
843            args.description.as_deref(),
844            args.details.as_deref(),
845            normalized_priority,
846            normalized_type.as_deref(),
847            args.plan.as_deref(),
848            args.parent.as_deref(),
849            &actor,
850        )?;
851    }
852
853    // Normalize and update status if provided
854    if let Some(ref status) = args.status {
855        let normalized = crate::validate::normalize_status(status)
856            .unwrap_or_else(|_| status.clone());
857        storage.update_issue_status(&args.id, &normalized, &actor)?;
858    }
859
860    if json {
861        let output = serde_json::json!({
862            "id": args.id,
863            "updated": true
864        });
865        println!("{output}");
866    } else {
867        println!("Updated issue: {}", args.id);
868    }
869
870    Ok(())
871}
872
873fn complete(ids: &[String], reason: Option<&str>, db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
874    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
875        .ok_or(Error::NotInitialized)?;
876
877    if !db_path.exists() {
878        return Err(Error::NotInitialized);
879    }
880
881    if crate::is_dry_run() {
882        for id in ids {
883            println!("Would complete issue: {id}");
884        }
885        return Ok(());
886    }
887
888    let mut storage = SqliteStorage::open(&db_path)?;
889    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
890
891    let mut results = Vec::new();
892    for id in ids {
893        storage.update_issue_status(id, "closed", &actor)?;
894        if let Some(reason) = reason {
895            storage.set_close_reason(id, reason, &actor)?;
896        }
897        results.push(id.as_str());
898    }
899
900    if crate::is_silent() {
901        for id in &results {
902            println!("{id}");
903        }
904    } else if json {
905        let mut output = serde_json::json!({
906            "ids": results,
907            "status": "closed",
908            "count": results.len()
909        });
910        if let Some(reason) = reason {
911            output["close_reason"] = serde_json::Value::String(reason.to_string());
912        }
913        println!("{output}");
914    } else {
915        for id in &results {
916            println!("Completed issue: {id}");
917        }
918        if let Some(reason) = reason {
919            println!("  Reason: {reason}");
920        }
921    }
922
923    Ok(())
924}
925
926fn claim(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
927    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
928        .ok_or(Error::NotInitialized)?;
929
930    if !db_path.exists() {
931        return Err(Error::NotInitialized);
932    }
933
934    if crate::is_dry_run() {
935        for id in ids {
936            println!("Would claim issue: {id}");
937        }
938        return Ok(());
939    }
940
941    let mut storage = SqliteStorage::open(&db_path)?;
942    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
943
944    let mut results = Vec::new();
945    for id in ids {
946        storage.claim_issue(id, &actor)?;
947        results.push(id.as_str());
948    }
949
950    if crate::is_silent() {
951        for id in &results {
952            println!("{id}");
953        }
954    } else if json {
955        let output = serde_json::json!({
956            "ids": results,
957            "status": "in_progress",
958            "assigned_to": actor,
959            "count": results.len()
960        });
961        println!("{output}");
962    } else {
963        for id in &results {
964            println!("Claimed issue: {id}");
965        }
966    }
967
968    Ok(())
969}
970
971fn release(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
972    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
973        .ok_or(Error::NotInitialized)?;
974
975    if !db_path.exists() {
976        return Err(Error::NotInitialized);
977    }
978
979    if crate::is_dry_run() {
980        for id in ids {
981            println!("Would release issue: {id}");
982        }
983        return Ok(());
984    }
985
986    let mut storage = SqliteStorage::open(&db_path)?;
987    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
988
989    let mut results = Vec::new();
990    for id in ids {
991        storage.release_issue(id, &actor)?;
992        results.push(id.as_str());
993    }
994
995    if crate::is_silent() {
996        for id in &results {
997            println!("{id}");
998        }
999    } else if json {
1000        let output = serde_json::json!({
1001            "ids": results,
1002            "status": "open",
1003            "count": results.len()
1004        });
1005        println!("{output}");
1006    } else {
1007        for id in &results {
1008            println!("Released issue: {id}");
1009        }
1010    }
1011
1012    Ok(())
1013}
1014
1015fn delete(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
1016    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1017        .ok_or(Error::NotInitialized)?;
1018
1019    if !db_path.exists() {
1020        return Err(Error::NotInitialized);
1021    }
1022
1023    if crate::is_dry_run() {
1024        for id in ids {
1025            println!("Would delete issue: {id}");
1026        }
1027        return Ok(());
1028    }
1029
1030    let mut storage = SqliteStorage::open(&db_path)?;
1031    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1032
1033    let mut results = Vec::new();
1034    for id in ids {
1035        storage.delete_issue(id, &actor)?;
1036        results.push(id.as_str());
1037    }
1038
1039    if crate::is_silent() {
1040        for id in &results {
1041            println!("{id}");
1042        }
1043    } else if json {
1044        let output = serde_json::json!({
1045            "ids": results,
1046            "deleted": true,
1047            "count": results.len()
1048        });
1049        println!("{output}");
1050    } else {
1051        for id in &results {
1052            println!("Deleted issue: {id}");
1053        }
1054    }
1055
1056    Ok(())
1057}
1058
1059/// Generate a short ID (4 hex chars).
1060fn generate_short_id() -> String {
1061    use std::time::{SystemTime, UNIX_EPOCH};
1062    let now = SystemTime::now()
1063        .duration_since(UNIX_EPOCH)
1064        .unwrap()
1065        .as_millis();
1066    format!("{:04x}", (now & 0xFFFF) as u16)
1067}
1068
1069fn label(
1070    command: &IssueLabelCommands,
1071    db_path: Option<&PathBuf>,
1072    actor: Option<&str>,
1073    json: bool,
1074) -> Result<()> {
1075    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1076        .ok_or(Error::NotInitialized)?;
1077
1078    if !db_path.exists() {
1079        return Err(Error::NotInitialized);
1080    }
1081
1082    let mut storage = SqliteStorage::open(&db_path)?;
1083    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1084
1085    match command {
1086        IssueLabelCommands::Add { id, labels } => {
1087            storage.add_issue_labels(id, labels, &actor)?;
1088
1089            if json {
1090                let output = serde_json::json!({
1091                    "id": id,
1092                    "action": "add",
1093                    "labels": labels
1094                });
1095                println!("{output}");
1096            } else {
1097                println!("Added labels to {}: {}", id, labels.join(", "));
1098            }
1099        }
1100        IssueLabelCommands::Remove { id, labels } => {
1101            storage.remove_issue_labels(id, labels, &actor)?;
1102
1103            if json {
1104                let output = serde_json::json!({
1105                    "id": id,
1106                    "action": "remove",
1107                    "labels": labels
1108                });
1109                println!("{output}");
1110            } else {
1111                println!("Removed labels from {}: {}", id, labels.join(", "));
1112            }
1113        }
1114    }
1115
1116    Ok(())
1117}
1118
1119fn dep(
1120    command: &IssueDepCommands,
1121    db_path: Option<&PathBuf>,
1122    actor: Option<&str>,
1123    json: bool,
1124) -> Result<()> {
1125    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1126        .ok_or(Error::NotInitialized)?;
1127
1128    if !db_path.exists() {
1129        return Err(Error::NotInitialized);
1130    }
1131
1132    let mut storage = SqliteStorage::open(&db_path)?;
1133    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1134
1135    match command {
1136        IssueDepCommands::Add { id, depends_on, dep_type } => {
1137            storage.add_issue_dependency(id, depends_on, dep_type, &actor)?;
1138
1139            if json {
1140                let output = serde_json::json!({
1141                    "issue_id": id,
1142                    "depends_on_id": depends_on,
1143                    "dependency_type": dep_type
1144                });
1145                println!("{output}");
1146            } else {
1147                println!("Added dependency: {} depends on {} ({})", id, depends_on, dep_type);
1148            }
1149        }
1150        IssueDepCommands::Remove { id, depends_on } => {
1151            storage.remove_issue_dependency(id, depends_on, &actor)?;
1152
1153            if json {
1154                let output = serde_json::json!({
1155                    "issue_id": id,
1156                    "depends_on_id": depends_on,
1157                    "removed": true
1158                });
1159                println!("{output}");
1160            } else {
1161                println!("Removed dependency: {} no longer depends on {}", id, depends_on);
1162            }
1163        }
1164        IssueDepCommands::Tree { id } => {
1165            return dep_tree(id.as_deref(), Some(&db_path), json);
1166        }
1167    }
1168
1169    Ok(())
1170}
1171
1172fn clone_issue(
1173    id: &str,
1174    new_title: Option<&str>,
1175    db_path: Option<&PathBuf>,
1176    actor: Option<&str>,
1177    json: bool,
1178) -> Result<()> {
1179    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1180        .ok_or(Error::NotInitialized)?;
1181
1182    if !db_path.exists() {
1183        return Err(Error::NotInitialized);
1184    }
1185
1186    let mut storage = SqliteStorage::open(&db_path)?;
1187    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1188
1189    let cloned = storage.clone_issue(id, new_title, &actor)?;
1190
1191    if json {
1192        println!("{}", serde_json::to_string(&cloned)?);
1193    } else {
1194        let short_id = cloned.short_id.as_deref().unwrap_or(&cloned.id[..8]);
1195        println!("Cloned issue {} to: {} [{}]", id, cloned.title, short_id);
1196    }
1197
1198    Ok(())
1199}
1200
1201fn duplicate(
1202    id: &str,
1203    duplicate_of: &str,
1204    db_path: Option<&PathBuf>,
1205    actor: Option<&str>,
1206    json: bool,
1207) -> Result<()> {
1208    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1209        .ok_or(Error::NotInitialized)?;
1210
1211    if !db_path.exists() {
1212        return Err(Error::NotInitialized);
1213    }
1214
1215    let mut storage = SqliteStorage::open(&db_path)?;
1216    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1217
1218    storage.mark_issue_duplicate(id, duplicate_of, &actor)?;
1219
1220    if json {
1221        let output = serde_json::json!({
1222            "id": id,
1223            "duplicate_of": duplicate_of,
1224            "status": "closed"
1225        });
1226        println!("{output}");
1227    } else {
1228        println!("Marked {} as duplicate of {} (closed)", id, duplicate_of);
1229    }
1230
1231    Ok(())
1232}
1233
1234fn ready(limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1235    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1236        .ok_or(Error::NotInitialized)?;
1237
1238    if !db_path.exists() {
1239        return Err(Error::NotInitialized);
1240    }
1241
1242    let storage = SqliteStorage::open(&db_path)?;
1243    let project_path = resolve_project_path(&storage, None)?;
1244
1245    #[allow(clippy::cast_possible_truncation)]
1246    let issues = storage.get_ready_issues(&project_path, limit as u32)?;
1247
1248    if json {
1249        let output = IssueListOutput {
1250            count: issues.len(),
1251            issues,
1252        };
1253        println!("{}", serde_json::to_string(&output)?);
1254    } else if issues.is_empty() {
1255        println!("No issues ready to work on.");
1256    } else {
1257        println!("Ready issues ({} found):", issues.len());
1258        println!();
1259        for issue in &issues {
1260            let priority_str = match issue.priority {
1261                4 => "!!",
1262                3 => "! ",
1263                2 => "  ",
1264                1 => "- ",
1265                0 => "--",
1266                _ => "  ",
1267            };
1268            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1269            println!(
1270                "○ [{}] {} {} ({})",
1271                short_id, priority_str, issue.title, issue.issue_type
1272            );
1273        }
1274    }
1275
1276    Ok(())
1277}
1278
1279fn next_block(
1280    count: usize,
1281    db_path: Option<&PathBuf>,
1282    actor: Option<&str>,
1283    json: bool,
1284) -> Result<()> {
1285    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1286        .ok_or(Error::NotInitialized)?;
1287
1288    if !db_path.exists() {
1289        return Err(Error::NotInitialized);
1290    }
1291
1292    let mut storage = SqliteStorage::open(&db_path)?;
1293    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1294    let project_path = resolve_project_path(&storage, None)?;
1295
1296    #[allow(clippy::cast_possible_truncation)]
1297    let issues = storage.get_next_issue_block(&project_path, count as u32, &actor)?;
1298
1299    if json {
1300        let output = IssueListOutput {
1301            count: issues.len(),
1302            issues,
1303        };
1304        println!("{}", serde_json::to_string(&output)?);
1305    } else if issues.is_empty() {
1306        println!("No issues available to claim.");
1307    } else {
1308        println!("Claimed {} issues:", issues.len());
1309        println!();
1310        for issue in &issues {
1311            let priority_str = match issue.priority {
1312                4 => "!!",
1313                3 => "! ",
1314                2 => "  ",
1315                1 => "- ",
1316                0 => "--",
1317                _ => "  ",
1318            };
1319            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1320            println!(
1321                "● [{}] {} {} ({})",
1322                short_id, priority_str, issue.title, issue.issue_type
1323            );
1324        }
1325    }
1326
1327    Ok(())
1328}
1329
1330/// Create multiple issues at once with dependencies.
1331fn batch(
1332    json_input: &str,
1333    db_path: Option<&PathBuf>,
1334    actor: Option<&str>,
1335    json: bool,
1336) -> Result<()> {
1337    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1338        .ok_or(Error::NotInitialized)?;
1339
1340    if !db_path.exists() {
1341        return Err(Error::NotInitialized);
1342    }
1343
1344    let mut storage = SqliteStorage::open(&db_path)?;
1345    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1346    let project_path = resolve_project_path(&storage, None)?;
1347
1348    // Parse the JSON input
1349    let input: BatchInput = serde_json::from_str(json_input)
1350        .map_err(|e| Error::Other(format!("Invalid JSON input: {e}")))?;
1351
1352    // Track created issue IDs by index for resolving $N references
1353    let mut created_ids: Vec<String> = Vec::with_capacity(input.issues.len());
1354    let mut results: Vec<BatchIssueResult> = Vec::with_capacity(input.issues.len());
1355
1356    // Create issues in order
1357    for (index, issue) in input.issues.iter().enumerate() {
1358        let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
1359        let short_id = generate_short_id();
1360
1361        // Resolve parent_id: if it starts with "$", look up created ID by index
1362        let resolved_parent_id = issue.parent_id.as_ref().and_then(|pid| {
1363            if let Some(idx_str) = pid.strip_prefix('$') {
1364                if let Ok(idx) = idx_str.parse::<usize>() {
1365                    created_ids.get(idx).cloned()
1366                } else {
1367                    Some(pid.clone())
1368                }
1369            } else {
1370                Some(pid.clone())
1371            }
1372        });
1373
1374        // Use issue-level plan_id, or fall back to batch-level plan_id
1375        let plan_id = issue.plan_id.as_ref().or(input.plan_id.as_ref());
1376
1377        storage.create_issue(
1378            &id,
1379            Some(&short_id),
1380            &project_path,
1381            &issue.title,
1382            issue.description.as_deref(),
1383            issue.details.as_deref(),
1384            issue.issue_type.as_deref(),
1385            issue.priority,
1386            plan_id.map(String::as_str),
1387            &actor,
1388        )?;
1389
1390        // Set parent via parent-child dependency if resolved
1391        if let Some(ref parent) = resolved_parent_id {
1392            storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
1393        }
1394
1395        // Add labels if provided
1396        if let Some(ref labels) = issue.labels {
1397            if !labels.is_empty() {
1398                storage.add_issue_labels(&id, labels, &actor)?;
1399            }
1400        }
1401
1402        created_ids.push(id.clone());
1403        results.push(BatchIssueResult {
1404            id,
1405            short_id: Some(short_id),
1406            title: issue.title.clone(),
1407            index,
1408        });
1409    }
1410
1411    // Create dependencies
1412    let mut dep_results: Vec<BatchDepResult> = Vec::new();
1413    if let Some(deps) = input.dependencies {
1414        for dep in deps {
1415            if dep.issue_index >= created_ids.len() || dep.depends_on_index >= created_ids.len() {
1416                return Err(Error::Other(format!(
1417                    "Dependency index out of range: {} -> {}",
1418                    dep.issue_index, dep.depends_on_index
1419                )));
1420            }
1421
1422            let issue_id = &created_ids[dep.issue_index];
1423            let depends_on_id = &created_ids[dep.depends_on_index];
1424            let dep_type = dep.dependency_type.as_deref().unwrap_or("blocks");
1425
1426            storage.add_issue_dependency(issue_id, depends_on_id, dep_type, &actor)?;
1427
1428            dep_results.push(BatchDepResult {
1429                issue_id: issue_id.clone(),
1430                depends_on_id: depends_on_id.clone(),
1431                dependency_type: dep_type.to_string(),
1432            });
1433        }
1434    }
1435
1436    let output = BatchOutput {
1437        issues: results,
1438        dependencies: dep_results,
1439    };
1440
1441    if json {
1442        println!("{}", serde_json::to_string(&output)?);
1443    } else {
1444        println!("Created {} issues:", output.issues.len());
1445        for result in &output.issues {
1446            let short_id = result.short_id.as_deref().unwrap_or(&result.id[..8]);
1447            println!("  [{}] {}", short_id, result.title);
1448        }
1449        if !output.dependencies.is_empty() {
1450            println!("\nCreated {} dependencies:", output.dependencies.len());
1451            for dep in &output.dependencies {
1452                println!("  {} -> {} ({})", dep.issue_id, dep.depends_on_id, dep.dependency_type);
1453            }
1454        }
1455    }
1456
1457    Ok(())
1458}
1459
1460fn count(group_by: &str, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1461    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1462        .ok_or(Error::NotInitialized)?;
1463
1464    if !db_path.exists() {
1465        return Err(Error::NotInitialized);
1466    }
1467
1468    let storage = SqliteStorage::open(&db_path)?;
1469    let project_path = resolve_project_path(&storage, None)?;
1470
1471    let groups = storage.count_issues_grouped(&project_path, group_by)?;
1472    let total: i64 = groups.iter().map(|(_, c)| c).sum();
1473
1474    if crate::is_csv() {
1475        println!("group,count");
1476        for (key, count) in &groups {
1477            println!("{},{count}", crate::csv_escape(key));
1478        }
1479    } else if json {
1480        let output = serde_json::json!({
1481            "groups": groups.iter().map(|(k, c)| {
1482                serde_json::json!({"key": k, "count": c})
1483            }).collect::<Vec<_>>(),
1484            "total": total,
1485            "group_by": group_by
1486        });
1487        println!("{output}");
1488    } else if groups.is_empty() {
1489        println!("No issues found.");
1490    } else {
1491        println!("Issues by {group_by}:");
1492        let max_key_len = groups.iter().map(|(k, _)| k.len()).max().unwrap_or(10);
1493        for (key, count) in &groups {
1494            println!("  {:<width$}  {count}", key, width = max_key_len);
1495        }
1496        println!("  {}", "─".repeat(max_key_len + 6));
1497        println!("  {:<width$}  {total}", "Total", width = max_key_len);
1498    }
1499
1500    Ok(())
1501}
1502
1503fn stale(days: u64, limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1504    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1505        .ok_or(Error::NotInitialized)?;
1506
1507    if !db_path.exists() {
1508        return Err(Error::NotInitialized);
1509    }
1510
1511    let storage = SqliteStorage::open(&db_path)?;
1512    let project_path = resolve_project_path(&storage, None)?;
1513
1514    #[allow(clippy::cast_possible_truncation)]
1515    let issues = storage.get_stale_issues(&project_path, days, limit as u32)?;
1516    let now_ms = chrono::Utc::now().timestamp_millis();
1517
1518    if crate::is_csv() {
1519        println!("id,title,status,priority,type,stale_days");
1520        for issue in &issues {
1521            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1522            let title = crate::csv_escape(&issue.title);
1523            let stale_d = (now_ms - issue.updated_at) / (24 * 60 * 60 * 1000);
1524            println!("{short_id},{title},{},{},{},{stale_d}", issue.status, issue.priority, issue.issue_type);
1525        }
1526    } else if json {
1527        let enriched: Vec<_> = issues.iter().map(|i| {
1528            let stale_d = (now_ms - i.updated_at) / (24 * 60 * 60 * 1000);
1529            serde_json::json!({
1530                "issue": i,
1531                "stale_days": stale_d
1532            })
1533        }).collect();
1534        let output = serde_json::json!({
1535            "issues": enriched,
1536            "count": issues.len(),
1537            "threshold_days": days
1538        });
1539        println!("{}", serde_json::to_string(&output)?);
1540    } else if issues.is_empty() {
1541        println!("No stale issues (threshold: {days} days).");
1542    } else {
1543        println!("Stale issues ({} found, threshold: {days} days):", issues.len());
1544        println!();
1545        for issue in &issues {
1546            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1547            let stale_d = (now_ms - issue.updated_at) / (24 * 60 * 60 * 1000);
1548            let status_icon = match issue.status.as_str() {
1549                "open" => "○",
1550                "in_progress" => "●",
1551                "blocked" => "⊘",
1552                _ => "?",
1553            };
1554            println!(
1555                "{status_icon} [{}] {} ({}) — last updated {stale_d} days ago",
1556                short_id, issue.title, issue.issue_type
1557            );
1558        }
1559    }
1560
1561    Ok(())
1562}
1563
1564fn blocked(limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1565    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1566        .ok_or(Error::NotInitialized)?;
1567
1568    if !db_path.exists() {
1569        return Err(Error::NotInitialized);
1570    }
1571
1572    let storage = SqliteStorage::open(&db_path)?;
1573    let project_path = resolve_project_path(&storage, None)?;
1574
1575    #[allow(clippy::cast_possible_truncation)]
1576    let blocked_issues = storage.get_blocked_issues(&project_path, limit as u32)?;
1577
1578    if crate::is_csv() {
1579        println!("id,title,status,blocked_by_ids");
1580        for (issue, blockers) in &blocked_issues {
1581            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1582            let title = crate::csv_escape(&issue.title);
1583            let blocker_ids: Vec<&str> = blockers.iter()
1584                .map(|b| b.short_id.as_deref().unwrap_or(&b.id[..8]))
1585                .collect();
1586            println!("{short_id},{title},{},{}", issue.status, blocker_ids.join(";"));
1587        }
1588    } else if json {
1589        let entries: Vec<_> = blocked_issues.iter().map(|(issue, blockers)| {
1590            serde_json::json!({
1591                "issue": issue,
1592                "blocked_by": blockers
1593            })
1594        }).collect();
1595        let output = serde_json::json!({
1596            "blocked_issues": entries,
1597            "count": blocked_issues.len()
1598        });
1599        println!("{}", serde_json::to_string(&output)?);
1600    } else if blocked_issues.is_empty() {
1601        println!("No blocked issues.");
1602    } else {
1603        println!("Blocked issues ({} found):", blocked_issues.len());
1604        println!();
1605        for (issue, blockers) in &blocked_issues {
1606            let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1607            println!("⊘ [{}] {} ({})", short_id, issue.title, issue.issue_type);
1608            for blocker in blockers {
1609                let b_short_id = blocker.short_id.as_deref().unwrap_or(&blocker.id[..8]);
1610                println!(
1611                    "    blocked by: [{}] {} [{}]",
1612                    b_short_id, blocker.title, blocker.status
1613                );
1614            }
1615        }
1616    }
1617
1618    Ok(())
1619}
1620
1621fn dep_tree(id: Option<&str>, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1622    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1623        .ok_or(Error::NotInitialized)?;
1624
1625    if !db_path.exists() {
1626        return Err(Error::NotInitialized);
1627    }
1628
1629    let storage = SqliteStorage::open(&db_path)?;
1630    let project_path = resolve_project_path(&storage, None)?;
1631
1632    if let Some(root_id) = id {
1633        // Show tree for a specific issue
1634        let tree = storage.get_dependency_tree(root_id)?;
1635        print_dep_tree(&tree, json)?;
1636    } else {
1637        // Show trees for all epics
1638        let epics = storage.get_epics(&project_path)?;
1639        if epics.is_empty() {
1640            if json {
1641                println!("{{\"trees\":[],\"count\":0}}");
1642            } else {
1643                println!("No epics found.");
1644            }
1645            return Ok(());
1646        }
1647
1648        if json {
1649            let mut trees = Vec::new();
1650            for epic in &epics {
1651                let tree = storage.get_dependency_tree(&epic.id)?;
1652                trees.push(tree_to_json(&tree));
1653            }
1654            let output = serde_json::json!({
1655                "trees": trees,
1656                "count": epics.len()
1657            });
1658            println!("{}", serde_json::to_string(&output)?);
1659        } else {
1660            for (i, epic) in epics.iter().enumerate() {
1661                if i > 0 {
1662                    println!();
1663                }
1664                let tree = storage.get_dependency_tree(&epic.id)?;
1665                print_ascii_tree(&tree);
1666            }
1667        }
1668    }
1669
1670    Ok(())
1671}
1672
1673fn print_dep_tree(tree: &[(crate::storage::Issue, i32)], json: bool) -> Result<()> {
1674    if json {
1675        let output = tree_to_json(tree);
1676        println!("{}", serde_json::to_string(&output)?);
1677    } else {
1678        print_ascii_tree(tree);
1679    }
1680    Ok(())
1681}
1682
1683fn tree_to_json(tree: &[(crate::storage::Issue, i32)]) -> serde_json::Value {
1684    if tree.is_empty() {
1685        return serde_json::json!(null);
1686    }
1687
1688    // Build nested structure from flat (issue, depth) list
1689    #[derive(serde::Serialize)]
1690    struct TreeNode {
1691        issue: serde_json::Value,
1692        children: Vec<TreeNode>,
1693    }
1694
1695    fn build_children(
1696        tree: &[(crate::storage::Issue, i32)],
1697        parent_idx: usize,
1698        parent_depth: i32,
1699    ) -> Vec<TreeNode> {
1700        let mut children = Vec::new();
1701        let mut i = parent_idx + 1;
1702        while i < tree.len() {
1703            let (ref issue, depth) = tree[i];
1704            if depth <= parent_depth {
1705                break;
1706            }
1707            if depth == parent_depth + 1 {
1708                let node = TreeNode {
1709                    issue: serde_json::to_value(issue).unwrap_or_default(),
1710                    children: build_children(tree, i, depth),
1711                };
1712                children.push(node);
1713            }
1714            i += 1;
1715        }
1716        children
1717    }
1718
1719    let (ref root, root_depth) = tree[0];
1720    let root_node = TreeNode {
1721        issue: serde_json::to_value(root).unwrap_or_default(),
1722        children: build_children(tree, 0, root_depth),
1723    };
1724
1725    serde_json::to_value(root_node).unwrap_or_default()
1726}
1727
1728fn print_ascii_tree(tree: &[(crate::storage::Issue, i32)]) {
1729    if tree.is_empty() {
1730        return;
1731    }
1732
1733    for (idx, (issue, depth)) in tree.iter().enumerate() {
1734        let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1735        let status_icon = match issue.status.as_str() {
1736            "open" => "○",
1737            "in_progress" => "●",
1738            "blocked" => "⊘",
1739            "closed" => "✓",
1740            "deferred" => "◌",
1741            _ => "?",
1742        };
1743
1744        if *depth == 0 {
1745            println!("{status_icon} {} [{}] {short_id}", issue.title, issue.issue_type);
1746        } else {
1747            // Find if this is the last child at this depth
1748            let is_last = !tree[idx + 1..].iter().any(|(_, d)| *d == *depth);
1749            let connector = if is_last { "└── " } else { "├── " };
1750            let indent: String = (1..*depth).map(|_| "│   ").collect();
1751            println!(
1752                "{indent}{connector}{status_icon} {} [{}] {short_id}",
1753                issue.title, issue.issue_type
1754            );
1755        }
1756    }
1757}