1use crate::cli::{
4 IssueCommands, IssueCreateArgs, IssueDepCommands, IssueLabelCommands, IssueListArgs,
5 IssueUpdateArgs,
6};
7use crate::config::{current_project_path, default_actor, resolve_db_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::Complete { ids } => complete(ids, db_path, actor, json),
111 IssueCommands::Claim { ids } => claim(ids, db_path, actor, json),
112 IssueCommands::Release { ids } => release(ids, db_path, actor, json),
113 IssueCommands::Delete { ids } => delete(ids, db_path, actor, json),
114 IssueCommands::Label { command } => label(command, db_path, actor, json),
115 IssueCommands::Dep { command } => dep(command, db_path, actor, json),
116 IssueCommands::Clone { id, title } => clone_issue(id, title.as_deref(), db_path, actor, json),
117 IssueCommands::Duplicate { id, of } => duplicate(id, of, db_path, actor, json),
118 IssueCommands::Ready { limit } => ready(*limit, db_path, json),
119 IssueCommands::NextBlock { count } => next_block(*count, db_path, actor, json),
120 IssueCommands::Batch { json_input } => batch(json_input, db_path, actor, json),
121 }
122}
123
124fn create(
125 args: &IssueCreateArgs,
126 db_path: Option<&PathBuf>,
127 actor: Option<&str>,
128 json: bool,
129) -> Result<()> {
130 if let Some(ref file_path) = args.file {
132 return create_from_file(file_path, db_path, actor, json);
133 }
134
135 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
136 .ok_or(Error::NotInitialized)?;
137
138 if !db_path.exists() {
139 return Err(Error::NotInitialized);
140 }
141
142 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
143 let project_path = current_project_path()
144 .map(|p| p.to_string_lossy().to_string())
145 .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
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
189 let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
191 let short_id = generate_short_id();
192
193 storage.create_issue(
194 &id,
195 Some(&short_id),
196 &project_path,
197 &args.title,
198 args.description.as_deref(),
199 args.details.as_deref(),
200 Some(&issue_type),
201 Some(priority),
202 args.plan_id.as_deref(),
203 &actor,
204 )?;
205
206 if let Some(ref parent) = args.parent {
208 storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
209 }
210
211 if let Some(ref labels) = args.labels {
213 if !labels.is_empty() {
214 storage.add_issue_labels(&id, labels, &actor)?;
215 }
216 }
217
218 if crate::is_silent() {
219 println!("{short_id}");
220 return Ok(());
221 }
222
223 if json {
224 let output = IssueCreateOutput {
225 id,
226 short_id: Some(short_id),
227 title: args.title.clone(),
228 status: "open".to_string(),
229 priority,
230 issue_type: issue_type.clone(),
231 };
232 println!("{}", serde_json::to_string(&output)?);
233 } else {
234 println!("Created issue: {} [{}]", args.title, short_id);
235 println!(" Type: {issue_type}");
236 println!(" Priority: {priority}");
237 }
238
239 Ok(())
240}
241
242fn create_from_file(
244 file_path: &PathBuf,
245 db_path: Option<&PathBuf>,
246 actor: Option<&str>,
247 json: bool,
248) -> Result<()> {
249 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
250 .ok_or(Error::NotInitialized)?;
251
252 if !db_path.exists() {
253 return Err(Error::NotInitialized);
254 }
255
256 let file = std::fs::File::open(file_path)
257 .map_err(|e| Error::Other(format!("Could not open file {}: {e}", file_path.display())))?;
258
259 let reader = std::io::BufReader::new(file);
260 let mut issues: Vec<BatchIssue> = Vec::new();
261
262 for (line_num, line) in reader.lines().enumerate() {
263 let line = line.map_err(|e| Error::Other(format!("Read error at line {}: {e}", line_num + 1)))?;
264 let trimmed = line.trim();
265 if trimmed.is_empty() || trimmed.starts_with('#') {
266 continue; }
268 let issue: BatchIssue = serde_json::from_str(trimmed)
269 .map_err(|e| Error::Other(format!("Invalid JSON at line {}: {e}", line_num + 1)))?;
270 issues.push(issue);
271 }
272
273 if issues.is_empty() {
274 return Err(Error::Other("No issues found in file".to_string()));
275 }
276
277 if crate::is_dry_run() {
279 if json {
280 let output = serde_json::json!({
281 "dry_run": true,
282 "action": "create_issues_from_file",
283 "file": file_path.display().to_string(),
284 "count": issues.len(),
285 });
286 println!("{output}");
287 } else {
288 println!("Would create {} issues from {}:", issues.len(), file_path.display());
289 for issue in &issues {
290 println!(" - {} [{}]", issue.title, issue.issue_type.as_deref().unwrap_or("task"));
291 }
292 }
293 return Ok(());
294 }
295
296 let mut storage = SqliteStorage::open(&db_path)?;
297 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
298 let project_path = current_project_path()
299 .map(|p| p.to_string_lossy().to_string())
300 .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
301
302 let mut results: Vec<BatchIssueResult> = Vec::with_capacity(issues.len());
303
304 for (index, issue) in issues.iter().enumerate() {
305 let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
306 let short_id = generate_short_id();
307
308 storage.create_issue(
309 &id,
310 Some(&short_id),
311 &project_path,
312 &issue.title,
313 issue.description.as_deref(),
314 issue.details.as_deref(),
315 issue.issue_type.as_deref(),
316 issue.priority,
317 issue.plan_id.as_deref(),
318 &actor,
319 )?;
320
321 if let Some(ref labels) = issue.labels {
322 if !labels.is_empty() {
323 storage.add_issue_labels(&id, labels, &actor)?;
324 }
325 }
326
327 results.push(BatchIssueResult {
328 id,
329 short_id: Some(short_id),
330 title: issue.title.clone(),
331 index,
332 });
333 }
334
335 if crate::is_silent() {
336 for r in &results {
337 println!("{}", r.short_id.as_deref().unwrap_or(&r.id));
338 }
339 return Ok(());
340 }
341
342 if json {
343 let output = serde_json::json!({
344 "issues": results,
345 "count": results.len(),
346 });
347 println!("{}", serde_json::to_string(&output)?);
348 } else {
349 println!("Created {} issues from {}:", results.len(), file_path.display());
350 for r in &results {
351 let sid = r.short_id.as_deref().unwrap_or(&r.id[..8]);
352 println!(" [{}] {}", sid, r.title);
353 }
354 }
355
356 Ok(())
357}
358
359fn list(args: &IssueListArgs, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
360 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
361 .ok_or(Error::NotInitialized)?;
362
363 if !db_path.exists() {
364 return Err(Error::NotInitialized);
365 }
366
367 let storage = SqliteStorage::open(&db_path)?;
368
369 if let Some(ref id) = args.id {
371 let project_path = current_project_path().map(|p| p.to_string_lossy().to_string());
372 let issue = storage
373 .get_issue(id, project_path.as_deref())?
374 .ok_or_else(|| {
375 let all_ids = storage.get_all_issue_short_ids().unwrap_or_default();
376 let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
377 if similar.is_empty() {
378 Error::IssueNotFound { id: id.to_string() }
379 } else {
380 Error::IssueNotFoundSimilar {
381 id: id.to_string(),
382 similar,
383 }
384 }
385 })?;
386 if json {
387 let output = IssueListOutput {
388 count: 1,
389 issues: vec![issue],
390 };
391 println!("{}", serde_json::to_string(&output)?);
392 } else {
393 print_issue_list(&[issue]);
394 }
395 return Ok(());
396 }
397
398 let project_path = if args.all_projects {
400 None
401 } else {
402 Some(
403 current_project_path()
404 .map(|p| p.to_string_lossy().to_string())
405 .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?,
406 )
407 };
408
409 let normalized_status = if args.status == "all" {
411 "all".to_string()
412 } else {
413 crate::validate::normalize_status(&args.status).unwrap_or_else(|_| args.status.clone())
414 };
415 let status = Some(normalized_status.as_str());
416
417 #[allow(clippy::cast_possible_truncation)]
419 let fetch_limit = (args.limit * 10).min(1000) as u32;
420
421 let issues = if let Some(ref path) = project_path {
422 storage.list_issues(path, status, args.issue_type.as_deref(), Some(fetch_limit))?
423 } else {
424 storage.list_all_issues(status, args.issue_type.as_deref(), Some(fetch_limit))?
428 };
429
430 let now = std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH)
433 .map(|d| d.as_secs() as i64)
434 .unwrap_or(0);
435
436 let child_ids = if let Some(ref parent) = args.parent {
438 Some(storage.get_child_issue_ids(parent)?)
439 } else {
440 None
441 };
442
443 let issues: Vec<_> = issues
444 .into_iter()
445 .filter(|i| {
447 if let Some(ref search) = args.search {
448 let s = search.to_lowercase();
449 i.title.to_lowercase().contains(&s)
450 || i.description
451 .as_ref()
452 .map(|d| d.to_lowercase().contains(&s))
453 .unwrap_or(false)
454 } else {
455 true
456 }
457 })
458 .filter(|i| args.priority.map_or(true, |p| i.priority == p))
460 .filter(|i| args.priority_min.map_or(true, |p| i.priority >= p))
462 .filter(|i| args.priority_max.map_or(true, |p| i.priority <= p))
463 .filter(|i| {
465 if let Some(ref child_set) = child_ids {
466 child_set.contains(&i.id)
468 } else {
469 true
470 }
471 })
472 .filter(|i| {
474 if let Some(ref plan) = args.plan {
475 i.plan_id.as_ref().map_or(false, |p| p == plan)
476 } else {
477 true
478 }
479 })
480 .filter(|i| {
482 if let Some(ref assignee) = args.assignee {
483 i.assigned_to_agent
484 .as_ref()
485 .map_or(false, |a| a == assignee)
486 } else {
487 true
488 }
489 })
490 .filter(|i| {
492 if let Some(days) = args.created_days {
493 let cutoff = now - (days * 24 * 60 * 60);
494 i.created_at >= cutoff
495 } else {
496 true
497 }
498 })
499 .filter(|i| {
500 if let Some(hours) = args.created_hours {
501 let cutoff = now - (hours * 60 * 60);
502 i.created_at >= cutoff
503 } else {
504 true
505 }
506 })
507 .filter(|i| {
509 if let Some(days) = args.updated_days {
510 let cutoff = now - (days * 24 * 60 * 60);
511 i.updated_at >= cutoff
512 } else {
513 true
514 }
515 })
516 .filter(|i| {
517 if let Some(hours) = args.updated_hours {
518 let cutoff = now - (hours * 60 * 60);
519 i.updated_at >= cutoff
520 } else {
521 true
522 }
523 })
524 .collect();
525
526 let issues: Vec<_> = if args.labels.is_some() || args.labels_any.is_some() {
528 issues
529 .into_iter()
530 .filter(|i| {
531 let issue_labels = storage.get_issue_labels(&i.id).unwrap_or_default();
532
533 let all_match = args.labels.as_ref().map_or(true, |required| {
535 required.iter().all(|l| issue_labels.contains(l))
536 });
537
538 let any_match = args.labels_any.as_ref().map_or(true, |required| {
540 required.iter().any(|l| issue_labels.contains(l))
541 });
542
543 all_match && any_match
544 })
545 .collect()
546 } else {
547 issues
548 };
549
550 let issues: Vec<_> = if args.has_deps || args.no_deps {
552 issues
553 .into_iter()
554 .filter(|i| {
555 let has_dependencies = storage.issue_has_dependencies(&i.id).unwrap_or(false);
556 if args.has_deps {
557 has_dependencies
558 } else {
559 !has_dependencies
560 }
561 })
562 .collect()
563 } else {
564 issues
565 };
566
567 let issues: Vec<_> = if args.has_subtasks || args.no_subtasks {
569 issues
570 .into_iter()
571 .filter(|i| {
572 let has_subtasks = storage.issue_has_subtasks(&i.id).unwrap_or(false);
573 if args.has_subtasks {
574 has_subtasks
575 } else {
576 !has_subtasks
577 }
578 })
579 .collect()
580 } else {
581 issues
582 };
583
584 let mut issues = issues;
586 match args.sort.as_str() {
587 "priority" => issues.sort_by(|a, b| {
588 if args.order == "asc" {
589 a.priority.cmp(&b.priority)
590 } else {
591 b.priority.cmp(&a.priority)
592 }
593 }),
594 "updatedAt" => issues.sort_by(|a, b| {
595 if args.order == "asc" {
596 a.updated_at.cmp(&b.updated_at)
597 } else {
598 b.updated_at.cmp(&a.updated_at)
599 }
600 }),
601 _ => {
602 issues.sort_by(|a, b| {
604 if args.order == "asc" {
605 a.created_at.cmp(&b.created_at)
606 } else {
607 b.created_at.cmp(&a.created_at)
608 }
609 });
610 }
611 }
612
613 issues.truncate(args.limit);
615
616 if crate::is_csv() {
617 println!("id,title,status,priority,type,assigned_to");
618 for issue in &issues {
619 let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
620 let title = crate::csv_escape(&issue.title);
621 let assignee = issue.assigned_to_agent.as_deref().unwrap_or("");
622 println!("{},{},{},{},{},{}", short_id, title, issue.status, issue.priority, issue.issue_type, assignee);
623 }
624 } else if json {
625 let output = IssueListOutput {
626 count: issues.len(),
627 issues,
628 };
629 println!("{}", serde_json::to_string(&output)?);
630 } else if issues.is_empty() {
631 println!("No issues found.");
632 } else {
633 print_issue_list(&issues);
634 }
635
636 Ok(())
637}
638
639fn print_issue_list(issues: &[crate::storage::Issue]) {
641 println!("Issues ({} found):", issues.len());
642 println!();
643 for issue in issues {
644 let status_icon = match issue.status.as_str() {
645 "open" => "○",
646 "in_progress" => "●",
647 "blocked" => "⊘",
648 "closed" => "✓",
649 "deferred" => "◌",
650 _ => "?",
651 };
652 let priority_str = match issue.priority {
653 4 => "!!",
654 3 => "! ",
655 2 => " ",
656 1 => "- ",
657 0 => "--",
658 _ => " ",
659 };
660 let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
661 println!(
662 "{} [{}] {} {} ({})",
663 status_icon, short_id, priority_str, issue.title, issue.issue_type
664 );
665 if let Some(ref desc) = issue.description {
666 let truncated = if desc.len() > 60 {
667 format!("{}...", &desc[..60])
668 } else {
669 desc.clone()
670 };
671 println!(" {truncated}");
672 }
673 }
674}
675
676fn show(id: &str, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
677 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
678 .ok_or(Error::NotInitialized)?;
679
680 if !db_path.exists() {
681 return Err(Error::NotInitialized);
682 }
683
684 let storage = SqliteStorage::open(&db_path)?;
685 let project_path = current_project_path().map(|p| p.to_string_lossy().to_string());
686
687 let issue = storage
688 .get_issue(id, project_path.as_deref())?
689 .ok_or_else(|| {
690 let all_ids = storage.get_all_issue_short_ids().unwrap_or_default();
691 let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
692 if similar.is_empty() {
693 Error::IssueNotFound { id: id.to_string() }
694 } else {
695 Error::IssueNotFoundSimilar {
696 id: id.to_string(),
697 similar,
698 }
699 }
700 })?;
701
702 if json {
703 println!("{}", serde_json::to_string(&issue)?);
704 } else {
705 let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
706 println!("[{}] {}", short_id, issue.title);
707 println!();
708 println!("Status: {}", issue.status);
709 println!("Type: {}", issue.issue_type);
710 println!("Priority: {}", issue.priority);
711 if let Some(ref desc) = issue.description {
712 println!();
713 println!("Description:");
714 println!("{desc}");
715 }
716 if let Some(ref details) = issue.details {
717 println!();
718 println!("Details:");
719 println!("{details}");
720 }
721 if let Some(ref agent) = issue.assigned_to_agent {
722 println!();
723 println!("Assigned to: {agent}");
724 }
725 }
726
727 Ok(())
728}
729
730fn update(
731 args: &IssueUpdateArgs,
732 db_path: Option<&PathBuf>,
733 actor: Option<&str>,
734 json: bool,
735) -> Result<()> {
736 if crate::is_dry_run() {
737 if json {
738 let output = serde_json::json!({
739 "dry_run": true,
740 "action": "update_issue",
741 "id": args.id,
742 });
743 println!("{output}");
744 } else {
745 println!("Would update issue: {}", args.id);
746 }
747 return Ok(());
748 }
749
750 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
751 .ok_or(Error::NotInitialized)?;
752
753 if !db_path.exists() {
754 return Err(Error::NotInitialized);
755 }
756
757 let mut storage = SqliteStorage::open(&db_path)?;
758 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
759
760 let normalized_type = args.issue_type.as_ref().map(|t| {
762 crate::validate::normalize_type(t).unwrap_or_else(|_| t.clone())
763 });
764
765 let normalized_priority = args.priority.map(|p| {
767 crate::validate::normalize_priority(&p.to_string()).unwrap_or(p)
768 });
769
770 let has_field_updates = args.title.is_some()
772 || args.description.is_some()
773 || args.details.is_some()
774 || normalized_priority.is_some()
775 || normalized_type.is_some()
776 || args.plan.is_some()
777 || args.parent.is_some();
778
779 if has_field_updates {
781 storage.update_issue(
782 &args.id,
783 args.title.as_deref(),
784 args.description.as_deref(),
785 args.details.as_deref(),
786 normalized_priority,
787 normalized_type.as_deref(),
788 args.plan.as_deref(),
789 args.parent.as_deref(),
790 &actor,
791 )?;
792 }
793
794 if let Some(ref status) = args.status {
796 let normalized = crate::validate::normalize_status(status)
797 .unwrap_or_else(|_| status.clone());
798 storage.update_issue_status(&args.id, &normalized, &actor)?;
799 }
800
801 if json {
802 let output = serde_json::json!({
803 "id": args.id,
804 "updated": true
805 });
806 println!("{output}");
807 } else {
808 println!("Updated issue: {}", args.id);
809 }
810
811 Ok(())
812}
813
814fn complete(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
815 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
816 .ok_or(Error::NotInitialized)?;
817
818 if !db_path.exists() {
819 return Err(Error::NotInitialized);
820 }
821
822 if crate::is_dry_run() {
823 for id in ids {
824 println!("Would complete issue: {id}");
825 }
826 return Ok(());
827 }
828
829 let mut storage = SqliteStorage::open(&db_path)?;
830 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
831
832 let mut results = Vec::new();
833 for id in ids {
834 storage.update_issue_status(id, "closed", &actor)?;
835 results.push(id.as_str());
836 }
837
838 if crate::is_silent() {
839 for id in &results {
840 println!("{id}");
841 }
842 } else if json {
843 let output = serde_json::json!({
844 "ids": results,
845 "status": "closed",
846 "count": results.len()
847 });
848 println!("{output}");
849 } else {
850 for id in &results {
851 println!("Completed issue: {id}");
852 }
853 }
854
855 Ok(())
856}
857
858fn claim(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
859 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
860 .ok_or(Error::NotInitialized)?;
861
862 if !db_path.exists() {
863 return Err(Error::NotInitialized);
864 }
865
866 if crate::is_dry_run() {
867 for id in ids {
868 println!("Would claim issue: {id}");
869 }
870 return Ok(());
871 }
872
873 let mut storage = SqliteStorage::open(&db_path)?;
874 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
875
876 let mut results = Vec::new();
877 for id in ids {
878 storage.claim_issue(id, &actor)?;
879 results.push(id.as_str());
880 }
881
882 if crate::is_silent() {
883 for id in &results {
884 println!("{id}");
885 }
886 } else if json {
887 let output = serde_json::json!({
888 "ids": results,
889 "status": "in_progress",
890 "assigned_to": actor,
891 "count": results.len()
892 });
893 println!("{output}");
894 } else {
895 for id in &results {
896 println!("Claimed issue: {id}");
897 }
898 }
899
900 Ok(())
901}
902
903fn release(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
904 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
905 .ok_or(Error::NotInitialized)?;
906
907 if !db_path.exists() {
908 return Err(Error::NotInitialized);
909 }
910
911 if crate::is_dry_run() {
912 for id in ids {
913 println!("Would release issue: {id}");
914 }
915 return Ok(());
916 }
917
918 let mut storage = SqliteStorage::open(&db_path)?;
919 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
920
921 let mut results = Vec::new();
922 for id in ids {
923 storage.release_issue(id, &actor)?;
924 results.push(id.as_str());
925 }
926
927 if crate::is_silent() {
928 for id in &results {
929 println!("{id}");
930 }
931 } else if json {
932 let output = serde_json::json!({
933 "ids": results,
934 "status": "open",
935 "count": results.len()
936 });
937 println!("{output}");
938 } else {
939 for id in &results {
940 println!("Released issue: {id}");
941 }
942 }
943
944 Ok(())
945}
946
947fn delete(ids: &[String], db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
948 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
949 .ok_or(Error::NotInitialized)?;
950
951 if !db_path.exists() {
952 return Err(Error::NotInitialized);
953 }
954
955 if crate::is_dry_run() {
956 for id in ids {
957 println!("Would delete issue: {id}");
958 }
959 return Ok(());
960 }
961
962 let mut storage = SqliteStorage::open(&db_path)?;
963 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
964
965 let mut results = Vec::new();
966 for id in ids {
967 storage.delete_issue(id, &actor)?;
968 results.push(id.as_str());
969 }
970
971 if crate::is_silent() {
972 for id in &results {
973 println!("{id}");
974 }
975 } else if json {
976 let output = serde_json::json!({
977 "ids": results,
978 "deleted": true,
979 "count": results.len()
980 });
981 println!("{output}");
982 } else {
983 for id in &results {
984 println!("Deleted issue: {id}");
985 }
986 }
987
988 Ok(())
989}
990
991fn generate_short_id() -> String {
993 use std::time::{SystemTime, UNIX_EPOCH};
994 let now = SystemTime::now()
995 .duration_since(UNIX_EPOCH)
996 .unwrap()
997 .as_millis();
998 format!("{:04x}", (now & 0xFFFF) as u16)
999}
1000
1001fn label(
1002 command: &IssueLabelCommands,
1003 db_path: Option<&PathBuf>,
1004 actor: Option<&str>,
1005 json: bool,
1006) -> Result<()> {
1007 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1008 .ok_or(Error::NotInitialized)?;
1009
1010 if !db_path.exists() {
1011 return Err(Error::NotInitialized);
1012 }
1013
1014 let mut storage = SqliteStorage::open(&db_path)?;
1015 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1016
1017 match command {
1018 IssueLabelCommands::Add { id, labels } => {
1019 storage.add_issue_labels(id, labels, &actor)?;
1020
1021 if json {
1022 let output = serde_json::json!({
1023 "id": id,
1024 "action": "add",
1025 "labels": labels
1026 });
1027 println!("{output}");
1028 } else {
1029 println!("Added labels to {}: {}", id, labels.join(", "));
1030 }
1031 }
1032 IssueLabelCommands::Remove { id, labels } => {
1033 storage.remove_issue_labels(id, labels, &actor)?;
1034
1035 if json {
1036 let output = serde_json::json!({
1037 "id": id,
1038 "action": "remove",
1039 "labels": labels
1040 });
1041 println!("{output}");
1042 } else {
1043 println!("Removed labels from {}: {}", id, labels.join(", "));
1044 }
1045 }
1046 }
1047
1048 Ok(())
1049}
1050
1051fn dep(
1052 command: &IssueDepCommands,
1053 db_path: Option<&PathBuf>,
1054 actor: Option<&str>,
1055 json: bool,
1056) -> Result<()> {
1057 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1058 .ok_or(Error::NotInitialized)?;
1059
1060 if !db_path.exists() {
1061 return Err(Error::NotInitialized);
1062 }
1063
1064 let mut storage = SqliteStorage::open(&db_path)?;
1065 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1066
1067 match command {
1068 IssueDepCommands::Add { id, depends_on, dep_type } => {
1069 storage.add_issue_dependency(id, depends_on, dep_type, &actor)?;
1070
1071 if json {
1072 let output = serde_json::json!({
1073 "issue_id": id,
1074 "depends_on_id": depends_on,
1075 "dependency_type": dep_type
1076 });
1077 println!("{output}");
1078 } else {
1079 println!("Added dependency: {} depends on {} ({})", id, depends_on, dep_type);
1080 }
1081 }
1082 IssueDepCommands::Remove { id, depends_on } => {
1083 storage.remove_issue_dependency(id, depends_on, &actor)?;
1084
1085 if json {
1086 let output = serde_json::json!({
1087 "issue_id": id,
1088 "depends_on_id": depends_on,
1089 "removed": true
1090 });
1091 println!("{output}");
1092 } else {
1093 println!("Removed dependency: {} no longer depends on {}", id, depends_on);
1094 }
1095 }
1096 }
1097
1098 Ok(())
1099}
1100
1101fn clone_issue(
1102 id: &str,
1103 new_title: Option<&str>,
1104 db_path: Option<&PathBuf>,
1105 actor: Option<&str>,
1106 json: bool,
1107) -> Result<()> {
1108 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1109 .ok_or(Error::NotInitialized)?;
1110
1111 if !db_path.exists() {
1112 return Err(Error::NotInitialized);
1113 }
1114
1115 let mut storage = SqliteStorage::open(&db_path)?;
1116 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1117
1118 let cloned = storage.clone_issue(id, new_title, &actor)?;
1119
1120 if json {
1121 println!("{}", serde_json::to_string(&cloned)?);
1122 } else {
1123 let short_id = cloned.short_id.as_deref().unwrap_or(&cloned.id[..8]);
1124 println!("Cloned issue {} to: {} [{}]", id, cloned.title, short_id);
1125 }
1126
1127 Ok(())
1128}
1129
1130fn duplicate(
1131 id: &str,
1132 duplicate_of: &str,
1133 db_path: Option<&PathBuf>,
1134 actor: Option<&str>,
1135 json: bool,
1136) -> Result<()> {
1137 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1138 .ok_or(Error::NotInitialized)?;
1139
1140 if !db_path.exists() {
1141 return Err(Error::NotInitialized);
1142 }
1143
1144 let mut storage = SqliteStorage::open(&db_path)?;
1145 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1146
1147 storage.mark_issue_duplicate(id, duplicate_of, &actor)?;
1148
1149 if json {
1150 let output = serde_json::json!({
1151 "id": id,
1152 "duplicate_of": duplicate_of,
1153 "status": "closed"
1154 });
1155 println!("{output}");
1156 } else {
1157 println!("Marked {} as duplicate of {} (closed)", id, duplicate_of);
1158 }
1159
1160 Ok(())
1161}
1162
1163fn ready(limit: usize, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
1164 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1165 .ok_or(Error::NotInitialized)?;
1166
1167 if !db_path.exists() {
1168 return Err(Error::NotInitialized);
1169 }
1170
1171 let storage = SqliteStorage::open(&db_path)?;
1172 let project_path = current_project_path()
1173 .map(|p| p.to_string_lossy().to_string())
1174 .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
1175
1176 #[allow(clippy::cast_possible_truncation)]
1177 let issues = storage.get_ready_issues(&project_path, limit as u32)?;
1178
1179 if json {
1180 let output = IssueListOutput {
1181 count: issues.len(),
1182 issues,
1183 };
1184 println!("{}", serde_json::to_string(&output)?);
1185 } else if issues.is_empty() {
1186 println!("No issues ready to work on.");
1187 } else {
1188 println!("Ready issues ({} found):", issues.len());
1189 println!();
1190 for issue in &issues {
1191 let priority_str = match issue.priority {
1192 4 => "!!",
1193 3 => "! ",
1194 2 => " ",
1195 1 => "- ",
1196 0 => "--",
1197 _ => " ",
1198 };
1199 let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1200 println!(
1201 "○ [{}] {} {} ({})",
1202 short_id, priority_str, issue.title, issue.issue_type
1203 );
1204 }
1205 }
1206
1207 Ok(())
1208}
1209
1210fn next_block(
1211 count: usize,
1212 db_path: Option<&PathBuf>,
1213 actor: Option<&str>,
1214 json: bool,
1215) -> Result<()> {
1216 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1217 .ok_or(Error::NotInitialized)?;
1218
1219 if !db_path.exists() {
1220 return Err(Error::NotInitialized);
1221 }
1222
1223 let mut storage = SqliteStorage::open(&db_path)?;
1224 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1225 let project_path = current_project_path()
1226 .map(|p| p.to_string_lossy().to_string())
1227 .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
1228
1229 #[allow(clippy::cast_possible_truncation)]
1230 let issues = storage.get_next_issue_block(&project_path, count as u32, &actor)?;
1231
1232 if json {
1233 let output = IssueListOutput {
1234 count: issues.len(),
1235 issues,
1236 };
1237 println!("{}", serde_json::to_string(&output)?);
1238 } else if issues.is_empty() {
1239 println!("No issues available to claim.");
1240 } else {
1241 println!("Claimed {} issues:", issues.len());
1242 println!();
1243 for issue in &issues {
1244 let priority_str = match issue.priority {
1245 4 => "!!",
1246 3 => "! ",
1247 2 => " ",
1248 1 => "- ",
1249 0 => "--",
1250 _ => " ",
1251 };
1252 let short_id = issue.short_id.as_deref().unwrap_or(&issue.id[..8]);
1253 println!(
1254 "● [{}] {} {} ({})",
1255 short_id, priority_str, issue.title, issue.issue_type
1256 );
1257 }
1258 }
1259
1260 Ok(())
1261}
1262
1263fn batch(
1265 json_input: &str,
1266 db_path: Option<&PathBuf>,
1267 actor: Option<&str>,
1268 json: bool,
1269) -> Result<()> {
1270 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
1271 .ok_or(Error::NotInitialized)?;
1272
1273 if !db_path.exists() {
1274 return Err(Error::NotInitialized);
1275 }
1276
1277 let mut storage = SqliteStorage::open(&db_path)?;
1278 let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
1279 let project_path = current_project_path()
1280 .map(|p| p.to_string_lossy().to_string())
1281 .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
1282
1283 let input: BatchInput = serde_json::from_str(json_input)
1285 .map_err(|e| Error::Other(format!("Invalid JSON input: {e}")))?;
1286
1287 let mut created_ids: Vec<String> = Vec::with_capacity(input.issues.len());
1289 let mut results: Vec<BatchIssueResult> = Vec::with_capacity(input.issues.len());
1290
1291 for (index, issue) in input.issues.iter().enumerate() {
1293 let id = format!("issue_{}", &uuid::Uuid::new_v4().to_string()[..12]);
1294 let short_id = generate_short_id();
1295
1296 let resolved_parent_id = issue.parent_id.as_ref().and_then(|pid| {
1298 if let Some(idx_str) = pid.strip_prefix('$') {
1299 if let Ok(idx) = idx_str.parse::<usize>() {
1300 created_ids.get(idx).cloned()
1301 } else {
1302 Some(pid.clone())
1303 }
1304 } else {
1305 Some(pid.clone())
1306 }
1307 });
1308
1309 let plan_id = issue.plan_id.as_ref().or(input.plan_id.as_ref());
1311
1312 storage.create_issue(
1313 &id,
1314 Some(&short_id),
1315 &project_path,
1316 &issue.title,
1317 issue.description.as_deref(),
1318 issue.details.as_deref(),
1319 issue.issue_type.as_deref(),
1320 issue.priority,
1321 plan_id.map(String::as_str),
1322 &actor,
1323 )?;
1324
1325 if let Some(ref parent) = resolved_parent_id {
1327 storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
1328 }
1329
1330 if let Some(ref labels) = issue.labels {
1332 if !labels.is_empty() {
1333 storage.add_issue_labels(&id, labels, &actor)?;
1334 }
1335 }
1336
1337 created_ids.push(id.clone());
1338 results.push(BatchIssueResult {
1339 id,
1340 short_id: Some(short_id),
1341 title: issue.title.clone(),
1342 index,
1343 });
1344 }
1345
1346 let mut dep_results: Vec<BatchDepResult> = Vec::new();
1348 if let Some(deps) = input.dependencies {
1349 for dep in deps {
1350 if dep.issue_index >= created_ids.len() || dep.depends_on_index >= created_ids.len() {
1351 return Err(Error::Other(format!(
1352 "Dependency index out of range: {} -> {}",
1353 dep.issue_index, dep.depends_on_index
1354 )));
1355 }
1356
1357 let issue_id = &created_ids[dep.issue_index];
1358 let depends_on_id = &created_ids[dep.depends_on_index];
1359 let dep_type = dep.dependency_type.as_deref().unwrap_or("blocks");
1360
1361 storage.add_issue_dependency(issue_id, depends_on_id, dep_type, &actor)?;
1362
1363 dep_results.push(BatchDepResult {
1364 issue_id: issue_id.clone(),
1365 depends_on_id: depends_on_id.clone(),
1366 dependency_type: dep_type.to_string(),
1367 });
1368 }
1369 }
1370
1371 let output = BatchOutput {
1372 issues: results,
1373 dependencies: dep_results,
1374 };
1375
1376 if json {
1377 println!("{}", serde_json::to_string(&output)?);
1378 } else {
1379 println!("Created {} issues:", output.issues.len());
1380 for result in &output.issues {
1381 let short_id = result.short_id.as_deref().unwrap_or(&result.id[..8]);
1382 println!(" [{}] {}", short_id, result.title);
1383 }
1384 if !output.dependencies.is_empty() {
1385 println!("\nCreated {} dependencies:", output.dependencies.len());
1386 for dep in &output.dependencies {
1387 println!(" {} -> {} ({})", dep.issue_id, dep.depends_on_id, dep.dependency_type);
1388 }
1389 }
1390 }
1391
1392 Ok(())
1393}