1use 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#[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#[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#[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#[derive(Debug, Serialize)]
58struct BatchOutput {
59 issues: Vec<BatchIssueResult>,
60 dependencies: Vec<BatchDepResult>,
61}
62
63#[derive(Debug, Serialize)]
65struct BatchIssueResult {
66 id: String,
67 short_id: Option<String>,
68 title: String,
69 index: usize,
70}
71
72#[derive(Debug, Serialize)]
74struct BatchDepResult {
75 issue_id: String,
76 depends_on_id: String,
77 dependency_type: String,
78}
79
80#[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#[derive(Serialize)]
93struct IssueListOutput {
94 issues: Vec<crate::storage::Issue>,
95 count: usize,
96}
97
98pub 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 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 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 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 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 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 if let Some(ref parent) = args.parent {
209 storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
210 }
211
212 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
243fn 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; }
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 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 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 let project_path = if args.all_projects {
399 None
400 } else {
401 Some(resolve_project_path(&storage, None)?)
402 };
403
404 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 #[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 storage.list_all_issues(status, args.issue_type.as_deref(), Some(fetch_limit))?
423 };
424
425 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 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(|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(|i| args.priority.map_or(true, |p| i.priority == p))
455 .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(|i| {
460 if let Some(ref child_set) = child_ids {
461 child_set.contains(&i.id)
463 } else {
464 true
465 }
466 })
467 .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(|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(|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(|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 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 let all_match = args.labels.as_ref().map_or(true, |required| {
530 required.iter().all(|l| issue_labels.contains(l))
531 });
532
533 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 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 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 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 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 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
634fn 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 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 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 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 let normalized_type = args.issue_type.as_ref().map(|t| {
811 crate::validate::normalize_type(t).unwrap_or_else(|_| t.clone())
812 });
813
814 let normalized_priority = args.priority.map(|p| {
816 crate::validate::normalize_priority(&p.to_string()).unwrap_or(p)
817 });
818
819 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 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 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
1049fn 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
1320fn 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 let input: BatchInput = serde_json::from_str(json_input)
1340 .map_err(|e| Error::Other(format!("Invalid JSON input: {e}")))?;
1341
1342 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 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 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 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 if let Some(ref parent) = resolved_parent_id {
1382 storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
1383 }
1384
1385 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 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 let tree = storage.get_dependency_tree(root_id)?;
1625 print_dep_tree(&tree, json)?;
1626 } else {
1627 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 #[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 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}