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 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 let normalized_type = args.issue_type.as_ref().map(|t| {
821 crate::validate::normalize_type(t).unwrap_or_else(|_| t.clone())
822 });
823
824 let normalized_priority = args.priority.map(|p| {
826 crate::validate::normalize_priority(&p.to_string()).unwrap_or(p)
827 });
828
829 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 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 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
1059fn 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
1330fn 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 let input: BatchInput = serde_json::from_str(json_input)
1350 .map_err(|e| Error::Other(format!("Invalid JSON input: {e}")))?;
1351
1352 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 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 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 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 if let Some(ref parent) = resolved_parent_id {
1392 storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
1393 }
1394
1395 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 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 let tree = storage.get_dependency_tree(root_id)?;
1635 print_dep_tree(&tree, json)?;
1636 } else {
1637 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 #[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 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}