Skip to main content

sc/cli/commands/
issue.rs

1//! Issue command implementations.
2
3use crate::cli::{
4    IssueCommands, IssueCreateArgs, IssueDepCommands, IssueLabelCommands, IssueListArgs,
5    IssueUpdateArgs,
6};
7use crate::config::{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/// Input for batch issue creation (from JSON).
15#[derive(Debug, Deserialize)]
16#[serde(rename_all = "camelCase")]
17struct BatchInput {
18    issues: Vec<BatchIssue>,
19    #[serde(default)]
20    dependencies: Option<Vec<BatchDependency>>,
21    #[serde(default)]
22    plan_id: Option<String>,
23}
24
25/// Single issue in batch input.
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "camelCase")]
28struct BatchIssue {
29    title: String,
30    #[serde(default)]
31    description: Option<String>,
32    #[serde(default)]
33    details: Option<String>,
34    #[serde(default)]
35    issue_type: Option<String>,
36    #[serde(default)]
37    priority: Option<i32>,
38    #[serde(default)]
39    parent_id: Option<String>,
40    #[serde(default)]
41    plan_id: Option<String>,
42    #[serde(default)]
43    labels: Option<Vec<String>>,
44}
45
46/// Dependency definition in batch input.
47#[derive(Debug, Deserialize)]
48#[serde(rename_all = "camelCase")]
49struct BatchDependency {
50    issue_index: usize,
51    depends_on_index: usize,
52    #[serde(default)]
53    dependency_type: Option<String>,
54}
55
56/// Output for batch creation.
57#[derive(Debug, Serialize)]
58struct BatchOutput {
59    issues: Vec<BatchIssueResult>,
60    dependencies: Vec<BatchDepResult>,
61}
62
63/// Result for single issue in batch.
64#[derive(Debug, Serialize)]
65struct BatchIssueResult {
66    id: String,
67    short_id: Option<String>,
68    title: String,
69    index: usize,
70}
71
72/// Result for dependency in batch.
73#[derive(Debug, Serialize)]
74struct BatchDepResult {
75    issue_id: String,
76    depends_on_id: String,
77    dependency_type: String,
78}
79
80/// Output for issue create.
81#[derive(Serialize)]
82struct IssueCreateOutput {
83    id: String,
84    short_id: Option<String>,
85    title: String,
86    status: String,
87    priority: i32,
88    issue_type: String,
89}
90
91/// Output for issue list.
92#[derive(Serialize)]
93struct IssueListOutput {
94    issues: Vec<crate::storage::Issue>,
95    count: usize,
96}
97
98/// Execute issue commands.
99pub fn execute(
100    command: &IssueCommands,
101    db_path: Option<&PathBuf>,
102    actor: Option<&str>,
103    json: bool,
104) -> Result<()> {
105    match command {
106        IssueCommands::Create(args) => create(args, db_path, actor, json),
107        IssueCommands::List(args) => list(args, db_path, json),
108        IssueCommands::Show { id } => show(id, db_path, json),
109        IssueCommands::Update(args) => update(args, db_path, actor, json),
110        IssueCommands::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    // Handle file-based bulk import
131    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    // Normalize type via synonym lookup
148    let issue_type = crate::validate::normalize_type(&args.issue_type)
149        .map_err(|(val, suggestion)| {
150            let msg = if let Some(s) = suggestion {
151                format!("Invalid issue type '{val}'. Did you mean '{s}'?")
152            } else {
153                format!("Invalid issue type '{val}'. Valid: task, bug, feature, epic, chore")
154            };
155            Error::InvalidArgument(msg)
156        })?;
157
158    // Normalize priority via synonym lookup
159    let priority = crate::validate::normalize_priority(&args.priority.to_string())
160        .map_err(|(val, suggestion)| {
161            let msg = suggestion.unwrap_or_else(|| format!("Invalid priority '{val}'"));
162            Error::InvalidArgument(msg)
163        })?;
164
165    // Dry-run: preview without writing
166    if crate::is_dry_run() {
167        let labels_str = args.labels.as_ref().map(|l| l.join(",")).unwrap_or_default();
168        if json {
169            let output = serde_json::json!({
170                "dry_run": true,
171                "action": "create_issue",
172                "title": args.title,
173                "issue_type": issue_type,
174                "priority": priority,
175                "labels": labels_str,
176            });
177            println!("{output}");
178        } else {
179            println!("Would create issue: {} [{}, priority={}]", args.title, issue_type, priority);
180            if !labels_str.is_empty() {
181                println!("  Labels: {labels_str}");
182            }
183        }
184        return Ok(());
185    }
186
187    let mut storage = SqliteStorage::open(&db_path)?;
188
189    // Generate IDs
190    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    // Set parent via parent-child dependency if provided
207    if let Some(ref parent) = args.parent {
208        storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
209    }
210
211    // Add labels if provided (already Vec from clap value_delimiter)
212    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
242/// Create issues from a JSONL file (one JSON object per line).
243fn 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; // Skip blank lines and comments
267        }
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    // Dry-run: just preview
278    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    // Handle single issue lookup by ID
370    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    // Determine project filter
399    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    // Normalize status filter via synonym lookup (e.g., "done" → "closed")
410    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    // Get base results from storage (fetch extra for post-filtering)
418    #[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        // For all_projects, we need to query without project filter
425        // Storage doesn't support this directly, so we need a workaround
426        // For now, get from storage with a higher limit
427        storage.list_all_issues(status, args.issue_type.as_deref(), Some(fetch_limit))?
428    };
429
430    // Apply post-filters
431    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    // Pre-fetch child IDs if filtering by parent
437    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 by search
446        .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 by exact priority
459        .filter(|i| args.priority.map_or(true, |p| i.priority == p))
460        // Filter by priority range
461        .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 by parent
464        .filter(|i| {
465            if let Some(ref child_set) = child_ids {
466                // Only include issues that are children of the specified parent
467                child_set.contains(&i.id)
468            } else {
469                true
470            }
471        })
472        // Filter by plan
473        .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 by assignee
481        .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 by created time
491        .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 by updated time
508        .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    // Apply label filtering
527    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                // Check --labels (all must match)
534                let all_match = args.labels.as_ref().map_or(true, |required| {
535                    required.iter().all(|l| issue_labels.contains(l))
536                });
537
538                // Check --labels-any (any must match)
539                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    // Apply has_deps/no_deps filtering
551    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    // Apply has_subtasks/no_subtasks filtering
568    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    // Apply sorting
585    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            // Default: createdAt
603            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    // Apply limit
614    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
639/// Print formatted issue list to stdout.
640fn 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    // Normalize type if provided
761    let normalized_type = args.issue_type.as_ref().map(|t| {
762        crate::validate::normalize_type(t).unwrap_or_else(|_| t.clone())
763    });
764
765    // Normalize priority if provided
766    let normalized_priority = args.priority.map(|p| {
767        crate::validate::normalize_priority(&p.to_string()).unwrap_or(p)
768    });
769
770    // Check if any non-status fields are being updated
771    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    // Update fields if any are provided
780    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    // Normalize and update status if provided
795    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
991/// Generate a short ID (4 hex chars).
992fn 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
1263/// Create multiple issues at once with dependencies.
1264fn 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    // Parse the JSON input
1284    let input: BatchInput = serde_json::from_str(json_input)
1285        .map_err(|e| Error::Other(format!("Invalid JSON input: {e}")))?;
1286
1287    // Track created issue IDs by index for resolving $N references
1288    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    // Create issues in order
1292    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        // Resolve parent_id: if it starts with "$", look up created ID by index
1297        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        // Use issue-level plan_id, or fall back to batch-level plan_id
1310        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        // Set parent via parent-child dependency if resolved
1326        if let Some(ref parent) = resolved_parent_id {
1327            storage.add_issue_dependency(&id, parent, "parent-child", &actor)?;
1328        }
1329
1330        // Add labels if provided
1331        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    // Create dependencies
1347    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}