Skip to main content

jira_cli/commands/
issues.rs

1use owo_colors::OwoColorize;
2
3use crate::api::{
4    ApiError, Issue, IssueDraft, IssueLink, IssueUpdate, JiraClient, Version, escape_jql,
5};
6use crate::output::{OutputConfig, use_color};
7
8/// Filter set shared by `issues list` and `issues mine`.
9///
10/// Each `Option<&str>` field maps to one CLI flag and emits one JQL clause when set.
11/// `components`, `labels`, and `fix_versions` are slices because the CLI accepts those flags repeatably.
12#[derive(Default)]
13pub struct ListFilters<'a> {
14    pub project: Option<&'a str>,
15    pub status: Option<&'a str>,
16    pub assignee: Option<&'a str>,
17    pub issue_type: Option<&'a str>,
18    pub sprint: Option<&'a str>,
19    pub components: Option<&'a [&'a str]>,
20    pub labels: Option<&'a [&'a str]>,
21    pub fix_versions: Option<&'a [&'a str]>,
22    pub jql_extra: Option<&'a str>,
23}
24
25pub async fn list(
26    client: &JiraClient,
27    out: &OutputConfig,
28    filters: ListFilters<'_>,
29    limit: usize,
30    offset: usize,
31    all: bool,
32) -> Result<(), ApiError> {
33    let jql = build_list_jql(&filters);
34    if all {
35        let issues = fetch_all_issues(client, &jql).await?;
36        let n = issues.len();
37        render_results(out, &issues, Some(n), 0, n, client, false);
38    } else {
39        let resp = client.search(&jql, limit, offset).await?;
40        let more = !resp.is_last;
41        render_results(
42            out,
43            &resp.issues,
44            resp.total,
45            resp.start_at,
46            resp.max_results,
47            client,
48            more,
49        );
50    }
51    Ok(())
52}
53
54/// List issues assigned to the current user.
55pub async fn mine(
56    client: &JiraClient,
57    out: &OutputConfig,
58    mut filters: ListFilters<'_>,
59    limit: usize,
60    all: bool,
61) -> Result<(), ApiError> {
62    filters.assignee = Some("me");
63    list(client, out, filters, limit, 0, all).await
64}
65
66/// List comments on an issue.
67pub async fn comments(client: &JiraClient, out: &OutputConfig, key: &str) -> Result<(), ApiError> {
68    let issue = client.get_issue(key).await?;
69    let comment_list = issue.fields.comment.as_ref();
70
71    if out.json {
72        let comments_json: Vec<serde_json::Value> = comment_list
73            .map(|cl| {
74                cl.comments
75                    .iter()
76                    .map(|c| {
77                        serde_json::json!({
78                            "id": c.id,
79                            "author": {
80                                "displayName": c.author.display_name,
81                                "accountId": c.author.account_id,
82                            },
83                            "body": c.body_text(),
84                            "created": c.created,
85                            "updated": c.updated,
86                        })
87                    })
88                    .collect()
89            })
90            .unwrap_or_default();
91        let total = comment_list.map(|cl| cl.total).unwrap_or(0);
92        out.print_data(
93            &serde_json::to_string_pretty(&serde_json::json!({
94                "issue": key,
95                "total": total,
96                "comments": comments_json,
97            }))
98            .expect("failed to serialize JSON"),
99        );
100    } else {
101        match comment_list {
102            None => {
103                out.print_message(&format!("No comments on {key}."));
104            }
105            Some(cl) if cl.comments.is_empty() => {
106                out.print_message(&format!("No comments on {key}."));
107            }
108            Some(cl) => {
109                let color = use_color();
110                out.print_message(&format!("Comments on {key} ({}):", cl.total));
111                for c in &cl.comments {
112                    println!();
113                    let author = if color {
114                        c.author.display_name.bold().to_string()
115                    } else {
116                        c.author.display_name.clone()
117                    };
118                    println!("  {} — {}", author, format_date(&c.created));
119                    for line in c.body_text().lines() {
120                        println!("    {line}");
121                    }
122                }
123            }
124        }
125    }
126    Ok(())
127}
128
129/// Fetch every page of a JQL search, returning all issues.
130pub async fn fetch_all_issues(client: &JiraClient, jql: &str) -> Result<Vec<Issue>, ApiError> {
131    const PAGE_SIZE: usize = 100;
132    let mut all: Vec<Issue> = Vec::new();
133    let mut offset = 0;
134    loop {
135        let resp = client.search(jql, PAGE_SIZE, offset).await?;
136        let fetched = resp.issues.len();
137        all.extend(resp.issues);
138        offset += fetched;
139        if resp.is_last || fetched == 0 {
140            break;
141        }
142    }
143    Ok(all)
144}
145
146fn render_results(
147    out: &OutputConfig,
148    issues: &[Issue],
149    total: Option<usize>,
150    start_at: usize,
151    max_results: usize,
152    client: &JiraClient,
153    more: bool,
154) {
155    if out.json {
156        let total_json: serde_json::Value = match total {
157            Some(n) => serde_json::json!(n),
158            None => serde_json::Value::Null,
159        };
160        out.print_data(
161            &serde_json::to_string_pretty(&serde_json::json!({
162                "total": total_json,
163                "startAt": start_at,
164                "maxResults": max_results,
165                "issues": issues.iter().map(|i| issue_to_json(i, client)).collect::<Vec<_>>(),
166            }))
167            .expect("failed to serialize JSON"),
168        );
169    } else {
170        render_issue_table(issues, out);
171        if more {
172            match total {
173                Some(n) => out.print_message(&format!(
174                    "Showing {}-{} of {} issues — use --limit/--offset or --all to paginate",
175                    start_at + 1,
176                    start_at + issues.len(),
177                    n
178                )),
179                None => out.print_message(&format!(
180                    "Showing {}-{} issues (more available) — use --limit/--offset or --all to paginate",
181                    start_at + 1,
182                    start_at + issues.len()
183                )),
184            }
185        } else {
186            out.print_message(&format!("{} issues", issues.len()));
187        }
188    }
189}
190
191pub async fn show(
192    client: &JiraClient,
193    out: &OutputConfig,
194    key: &str,
195    open: bool,
196) -> Result<(), ApiError> {
197    let issue = client.get_issue(key).await?;
198
199    if open {
200        open_in_browser(&client.browse_url(&issue.key));
201    }
202
203    if out.json {
204        out.print_data(
205            &serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
206                .expect("failed to serialize JSON"),
207        );
208    } else {
209        render_issue_detail(&issue);
210    }
211    Ok(())
212}
213
214pub async fn create(
215    client: &JiraClient,
216    out: &OutputConfig,
217    draft: &IssueDraft<'_>,
218    sprint: Option<&str>,
219    custom_fields: &[(String, serde_json::Value)],
220) -> Result<(), ApiError> {
221    let resp = client.create_issue(draft, custom_fields).await?;
222    let url = client.browse_url(&resp.key);
223
224    let mut result = serde_json::json!({ "key": resp.key, "id": resp.id, "url": url });
225    if let Some(p) = draft.parent {
226        result["parent"] = serde_json::json!(p);
227    }
228    if let Some(s) = sprint {
229        let resolved = client.resolve_sprint(s).await?;
230        client.move_issue_to_sprint(&resp.key, resolved.id).await?;
231        result["sprintId"] = serde_json::json!(resolved.id);
232        result["sprintName"] = serde_json::json!(resolved.name);
233    }
234    out.print_result(&result, &resp.key);
235    Ok(())
236}
237
238pub async fn update(
239    client: &JiraClient,
240    out: &OutputConfig,
241    key: &str,
242    update: &IssueUpdate<'_>,
243    custom_fields: &[(String, serde_json::Value)],
244) -> Result<(), ApiError> {
245    client.update_issue(key, update, custom_fields).await?;
246    out.print_result(
247        &serde_json::json!({ "key": key, "updated": true }),
248        &format!("Updated {key}"),
249    );
250    Ok(())
251}
252
253/// Move an issue to a sprint.
254pub async fn move_to_sprint(
255    client: &JiraClient,
256    out: &OutputConfig,
257    key: &str,
258    sprint: &str,
259) -> Result<(), ApiError> {
260    let resolved = client.resolve_sprint(sprint).await?;
261    client.move_issue_to_sprint(key, resolved.id).await?;
262    out.print_result(
263        &serde_json::json!({
264            "issue": key,
265            "sprintId": resolved.id,
266            "sprintName": resolved.name,
267        }),
268        &format!("Moved {key} to {} ({})", resolved.name, resolved.id),
269    );
270    Ok(())
271}
272
273pub async fn comment(
274    client: &JiraClient,
275    out: &OutputConfig,
276    key: &str,
277    body: &str,
278) -> Result<(), ApiError> {
279    let c = client.add_comment(key, body).await?;
280    let url = client.browse_url(key);
281    out.print_result(
282        &serde_json::json!({
283            "id": c.id,
284            "issue": key,
285            "url": url,
286            "author": c.author.display_name,
287            "created": c.created,
288        }),
289        &format!("Comment added to {key}"),
290    );
291    Ok(())
292}
293
294pub async fn transition(
295    client: &JiraClient,
296    out: &OutputConfig,
297    key: &str,
298    to: &str,
299) -> Result<(), ApiError> {
300    let transitions = client.get_transitions(key).await?;
301
302    let matched = transitions
303        .iter()
304        .find(|t| t.name.to_lowercase() == to.to_lowercase() || t.id == to);
305
306    match matched {
307        Some(t) => {
308            let name = t.name.clone();
309            let id = t.id.clone();
310            let status =
311                t.to.as_ref()
312                    .map(|tt| tt.name.clone())
313                    .unwrap_or_else(|| name.clone());
314            client.do_transition(key, &id).await?;
315            out.print_result(
316                &serde_json::json!({ "issue": key, "transition": name, "status": status, "id": id }),
317                &format!("Transitioned {key} → {status}"),
318            );
319        }
320        None => {
321            let hint = transitions
322                .iter()
323                .map(|t| format!("  {} ({})", t.name, t.id))
324                .collect::<Vec<_>>()
325                .join("\n");
326            out.print_message(&format!(
327                "Transition '{to}' not found for {key}. Available:\n{hint}"
328            ));
329            out.print_message(&format!(
330                "Tip: `jira issues list-transitions {key}` shows transitions as JSON."
331            ));
332            return Err(ApiError::NotFound(format!(
333                "Transition '{to}' not found for {key}"
334            )));
335        }
336    }
337    Ok(())
338}
339
340pub async fn list_transitions(
341    client: &JiraClient,
342    out: &OutputConfig,
343    key: &str,
344) -> Result<(), ApiError> {
345    let ts = client.get_transitions(key).await?;
346
347    if out.json {
348        out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
349    } else {
350        let color = use_color();
351        let header = format!("{:<6} {}", "ID", "Name");
352        if color {
353            println!("{}", header.bold());
354        } else {
355            println!("{header}");
356        }
357        for t in &ts {
358            println!("{:<6} {}", t.id, t.name);
359        }
360    }
361    Ok(())
362}
363
364pub async fn assign(
365    client: &JiraClient,
366    out: &OutputConfig,
367    key: &str,
368    assignee: &str,
369) -> Result<(), ApiError> {
370    let account_id = if assignee == "me" {
371        let me = client.get_myself().await?;
372        me.account_id
373    } else if assignee == "none" || assignee == "unassign" {
374        client.assign_issue(key, None).await?;
375        out.print_result(
376            &serde_json::json!({ "issue": key, "assignee": null }),
377            &format!("Unassigned {key}"),
378        );
379        return Ok(());
380    } else {
381        assignee.to_string()
382    };
383
384    client.assign_issue(key, Some(&account_id)).await?;
385    out.print_result(
386        &serde_json::json!({ "issue": key, "accountId": account_id }),
387        &format!("Assigned {key} to {assignee}"),
388    );
389    Ok(())
390}
391
392/// List available issue link types.
393pub async fn link_types(client: &JiraClient, out: &OutputConfig) -> Result<(), ApiError> {
394    let types = client.get_link_types().await?;
395
396    if out.json {
397        out.print_data(
398            &serde_json::to_string_pretty(&serde_json::json!(
399                types
400                    .iter()
401                    .map(|t| serde_json::json!({
402                        "id": t.id,
403                        "name": t.name,
404                        "inward": t.inward,
405                        "outward": t.outward,
406                    }))
407                    .collect::<Vec<_>>()
408            ))
409            .expect("failed to serialize JSON"),
410        );
411        return Ok(());
412    }
413
414    for t in &types {
415        println!(
416            "{:<20}  outward: {}  /  inward: {}",
417            t.name, t.outward, t.inward
418        );
419    }
420    Ok(())
421}
422
423/// Link two issues.
424pub async fn link(
425    client: &JiraClient,
426    out: &OutputConfig,
427    from_key: &str,
428    to_key: &str,
429    link_type: &str,
430) -> Result<(), ApiError> {
431    client.link_issues(from_key, to_key, link_type).await?;
432    out.print_result(
433        &serde_json::json!({
434            "from": from_key,
435            "to": to_key,
436            "type": link_type,
437        }),
438        &format!("Linked {from_key} → {to_key} ({link_type})"),
439    );
440    Ok(())
441}
442
443/// Remove an issue link by link ID.
444pub async fn unlink(
445    client: &JiraClient,
446    out: &OutputConfig,
447    link_id: &str,
448) -> Result<(), ApiError> {
449    client.unlink_issues(link_id).await?;
450    out.print_result(
451        &serde_json::json!({ "linkId": link_id }),
452        &format!("Removed link {link_id}"),
453    );
454    Ok(())
455}
456
457/// Log work (time) on an issue.
458pub async fn log_work(
459    client: &JiraClient,
460    out: &OutputConfig,
461    key: &str,
462    time_spent: &str,
463    comment: Option<&str>,
464    started: Option<&str>,
465) -> Result<(), ApiError> {
466    let entry = client.log_work(key, time_spent, comment, started).await?;
467    out.print_result(
468        &serde_json::json!({
469            "id": entry.id,
470            "issue": key,
471            "timeSpent": entry.time_spent,
472            "timeSpentSeconds": entry.time_spent_seconds,
473            "author": entry.author.display_name,
474            "started": entry.started,
475            "created": entry.created,
476        }),
477        &format!("Logged {} on {key}", entry.time_spent),
478    );
479    Ok(())
480}
481
482/// Transition all issues matching a JQL query to a new status.
483pub async fn bulk_transition(
484    client: &JiraClient,
485    out: &OutputConfig,
486    jql: &str,
487    to: &str,
488    dry_run: bool,
489) -> Result<(), ApiError> {
490    let issues = fetch_all_issues(client, jql).await?;
491
492    if issues.is_empty() {
493        out.print_message("No issues matched the query.");
494        return Ok(());
495    }
496
497    let mut results: Vec<serde_json::Value> = Vec::new();
498    let mut succeeded = 0usize;
499    let mut failed = 0usize;
500
501    for issue in &issues {
502        if dry_run {
503            results.push(serde_json::json!({
504                "key": issue.key,
505                "status": issue.status(),
506                "action": "would transition",
507                "to": to,
508            }));
509            continue;
510        }
511
512        let transitions = client.get_transitions(&issue.key).await?;
513        let matched = transitions.iter().find(|t| {
514            t.name.eq_ignore_ascii_case(to)
515                || t.to
516                    .as_ref()
517                    .is_some_and(|tt| tt.name.eq_ignore_ascii_case(to))
518                || t.id == to
519        });
520
521        match matched {
522            Some(t) => match client.do_transition(&issue.key, &t.id).await {
523                Ok(()) => {
524                    succeeded += 1;
525                    results.push(serde_json::json!({
526                        "key": issue.key,
527                        "from": issue.status(),
528                        "to": to,
529                        "ok": true,
530                    }));
531                }
532                Err(e) => {
533                    failed += 1;
534                    results.push(serde_json::json!({
535                        "key": issue.key,
536                        "ok": false,
537                        "error": e.to_string(),
538                    }));
539                }
540            },
541            None => {
542                failed += 1;
543                results.push(serde_json::json!({
544                    "key": issue.key,
545                    "ok": false,
546                    "error": format!("transition '{to}' not available"),
547                }));
548            }
549        }
550    }
551
552    if out.json {
553        out.print_data(
554            &serde_json::to_string_pretty(&serde_json::json!({
555                "dryRun": dry_run,
556                "total": issues.len(),
557                "succeeded": succeeded,
558                "failed": failed,
559                "issues": results,
560            }))
561            .expect("failed to serialize JSON"),
562        );
563    } else if dry_run {
564        render_issue_table(&issues, out);
565        out.print_message(&format!(
566            "Dry run: {} issues would be transitioned to '{to}'",
567            issues.len()
568        ));
569    } else {
570        out.print_message(&format!(
571            "Transitioned {succeeded}/{} issues to '{to}'{}",
572            issues.len(),
573            if failed > 0 {
574                format!(" ({failed} failed)")
575            } else {
576                String::new()
577            }
578        ));
579    }
580    Ok(())
581}
582
583/// Assign all issues matching a JQL query to a user.
584pub async fn bulk_assign(
585    client: &JiraClient,
586    out: &OutputConfig,
587    jql: &str,
588    assignee: &str,
589    dry_run: bool,
590) -> Result<(), ApiError> {
591    // Resolve "me" once before the loop.
592    let account_id: Option<String> = match assignee {
593        "me" => {
594            let me = client.get_myself().await?;
595            Some(me.account_id)
596        }
597        "none" | "unassign" => None,
598        id => Some(id.to_string()),
599    };
600
601    let issues = fetch_all_issues(client, jql).await?;
602
603    if issues.is_empty() {
604        out.print_message("No issues matched the query.");
605        return Ok(());
606    }
607
608    let mut results: Vec<serde_json::Value> = Vec::new();
609    let mut succeeded = 0usize;
610    let mut failed = 0usize;
611
612    for issue in &issues {
613        if dry_run {
614            results.push(serde_json::json!({
615                "key": issue.key,
616                "currentAssignee": issue.assignee(),
617                "action": "would assign",
618                "to": assignee,
619            }));
620            continue;
621        }
622
623        match client.assign_issue(&issue.key, account_id.as_deref()).await {
624            Ok(()) => {
625                succeeded += 1;
626                results.push(serde_json::json!({
627                    "key": issue.key,
628                    "assignee": assignee,
629                    "ok": true,
630                }));
631            }
632            Err(e) => {
633                failed += 1;
634                results.push(serde_json::json!({
635                    "key": issue.key,
636                    "ok": false,
637                    "error": e.to_string(),
638                }));
639            }
640        }
641    }
642
643    if out.json {
644        out.print_data(
645            &serde_json::to_string_pretty(&serde_json::json!({
646                "dryRun": dry_run,
647                "total": issues.len(),
648                "succeeded": succeeded,
649                "failed": failed,
650                "issues": results,
651            }))
652            .expect("failed to serialize JSON"),
653        );
654    } else if dry_run {
655        render_issue_table(&issues, out);
656        out.print_message(&format!(
657            "Dry run: {} issues would be assigned to '{assignee}'",
658            issues.len()
659        ));
660    } else {
661        out.print_message(&format!(
662            "Assigned {succeeded}/{} issues to '{assignee}'{}",
663            issues.len(),
664            if failed > 0 {
665                format!(" ({failed} failed)")
666            } else {
667                String::new()
668            }
669        ));
670    }
671    Ok(())
672}
673
674// ── Rendering ─────────────────────────────────────────────────────────────────
675
676pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
677    if issues.is_empty() {
678        out.print_message("No issues found.");
679        return;
680    }
681
682    let color = use_color();
683    let term_width = terminal_width();
684
685    let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
686    let status_w = issues
687        .iter()
688        .map(|i| i.status().len())
689        .max()
690        .unwrap_or(6)
691        .clamp(6, 14)
692        + 2;
693    let assignee_w = issues
694        .iter()
695        .map(|i| i.assignee().len())
696        .max()
697        .unwrap_or(8)
698        .clamp(8, 18)
699        + 2;
700    let type_w = issues
701        .iter()
702        .map(|i| i.issue_type().len())
703        .max()
704        .unwrap_or(4)
705        .clamp(4, 12)
706        + 2;
707
708    // Give remaining width to summary, minimum 20
709    let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
710    let summary_w = term_width.saturating_sub(fixed).max(20);
711
712    let header = format!(
713        "{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
714        "Key", "Status", "Assignee", "Type", "Summary"
715    );
716    if color {
717        println!("{}", header.bold());
718    } else {
719        println!("{header}");
720    }
721
722    for issue in issues {
723        let key = if color {
724            format!("{:<key_w$}", issue.key).yellow().to_string()
725        } else {
726            format!("{:<key_w$}", issue.key)
727        };
728        let status_val = truncate(issue.status(), status_w - 2);
729        let status = if color {
730            colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
731        } else {
732            format!("{:<status_w$}", status_val)
733        };
734        println!(
735            "{key} {status} {:<assignee_w$} {:<type_w$} {}",
736            truncate(issue.assignee(), assignee_w - 2),
737            truncate(issue.issue_type(), type_w - 2),
738            truncate(issue.summary(), summary_w),
739        );
740    }
741}
742
743fn render_issue_detail(issue: &Issue) {
744    let mut stdout = std::io::stdout().lock();
745    write_issue_detail(&mut stdout, issue).expect("stdout write");
746}
747
748fn write_issue_detail<W: std::io::Write>(out: &mut W, issue: &Issue) -> std::io::Result<()> {
749    let color = use_color();
750    let key = if color {
751        issue.key.yellow().bold().to_string()
752    } else {
753        issue.key.clone()
754    };
755    writeln!(out, "{key}  {}", issue.summary())?;
756    writeln!(out)?;
757    writeln!(out, "  Type:       {}", issue.issue_type())?;
758    let status_str = if color {
759        colorize_status(issue.status(), issue.status())
760    } else {
761        issue.status().to_string()
762    };
763    writeln!(out, "  Status:     {status_str}")?;
764    writeln!(out, "  Priority:   {}", issue.priority())?;
765    writeln!(out, "  Assignee:   {}", issue.assignee())?;
766    if let Some(ref reporter) = issue.fields.reporter {
767        writeln!(out, "  Reporter:   {}", reporter.display_name)?;
768    }
769    if let Some(ref labels) = issue.fields.labels
770        && !labels.is_empty()
771    {
772        writeln!(out, "  Labels:     {}", labels.join(", "))?;
773    }
774    if let Some(ref components) = issue.fields.components
775        && !components.is_empty()
776    {
777        let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
778        writeln!(out, "  Components: {}", names.join(", "))?;
779    }
780    if let Some(ref fix_versions) = issue.fields.fix_versions
781        && !fix_versions.is_empty()
782    {
783        let names: Vec<&str> = fix_versions.iter().map(|v| v.name.as_str()).collect();
784        writeln!(out, "  Fix Versions:     {}", names.join(", "))?;
785    }
786    if let Some(ref versions) = issue.fields.versions
787        && !versions.is_empty()
788    {
789        let names: Vec<&str> = versions.iter().map(|v| v.name.as_str()).collect();
790        writeln!(out, "  Affects Versions: {}", names.join(", "))?;
791    }
792    if let Some(ref created) = issue.fields.created {
793        writeln!(out, "  Created:    {}", format_date(created))?;
794    }
795    if let Some(ref updated) = issue.fields.updated {
796        writeln!(out, "  Updated:    {}", format_date(updated))?;
797    }
798
799    let desc = issue.description_text();
800    if !desc.is_empty() {
801        writeln!(out)?;
802        writeln!(out, "Description:")?;
803        for line in desc.lines() {
804            writeln!(out, "  {line}")?;
805        }
806    }
807
808    if let Some(ref links) = issue.fields.issue_links
809        && !links.is_empty()
810    {
811        writeln!(out)?;
812        writeln!(out, "Links:")?;
813        for link in links {
814            write_issue_link(out, link)?;
815        }
816    }
817
818    if let Some(ref comment_list) = issue.fields.comment
819        && !comment_list.comments.is_empty()
820    {
821        writeln!(out)?;
822        writeln!(out, "Comments ({}):", comment_list.total)?;
823        for c in &comment_list.comments {
824            writeln!(out)?;
825            let author = if color {
826                c.author.display_name.bold().to_string()
827            } else {
828                c.author.display_name.clone()
829            };
830            writeln!(out, "  {} — {}", author, format_date(&c.created))?;
831            let body = c.body_text();
832            for line in body.lines() {
833                writeln!(out, "    {line}")?;
834            }
835        }
836    }
837    Ok(())
838}
839
840fn write_issue_link<W: std::io::Write>(out: &mut W, link: &IssueLink) -> std::io::Result<()> {
841    if let Some(ref out_issue) = link.outward_issue {
842        writeln!(
843            out,
844            "  [{}] {} {} — {}",
845            link.id, link.link_type.outward, out_issue.key, out_issue.fields.summary
846        )?;
847    }
848    if let Some(ref in_issue) = link.inward_issue {
849        writeln!(
850            out,
851            "  [{}] {} {} — {}",
852            link.id, link.link_type.inward, in_issue.key, in_issue.fields.summary
853        )?;
854    }
855    Ok(())
856}
857
858// ── JSON serialization ────────────────────────────────────────────────────────
859
860pub(crate) fn issue_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
861    serde_json::json!({
862        "key": issue.key,
863        "id": issue.id,
864        "url": client.browse_url(&issue.key),
865        "summary": issue.summary(),
866        "status": issue.status(),
867        "assignee": {
868            "displayName": issue.assignee(),
869            "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
870        },
871        "priority": issue.priority(),
872        "type": issue.issue_type(),
873        "created": issue.fields.created,
874        "updated": issue.fields.updated,
875    })
876}
877
878fn version_to_json(v: &Version) -> serde_json::Value {
879    serde_json::json!({
880        "id": v.id,
881        "name": v.name,
882        "description": v.description,
883        "released": v.released,
884        "archived": v.archived,
885        "releaseDate": v.release_date,
886    })
887}
888
889pub fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
890    let comments: Vec<serde_json::Value> = issue
891        .fields
892        .comment
893        .as_ref()
894        .map(|cl| {
895            cl.comments
896                .iter()
897                .map(|c| {
898                    serde_json::json!({
899                        "id": c.id,
900                        "author": {
901                            "displayName": c.author.display_name,
902                            "accountId": c.author.account_id,
903                        },
904                        "body": c.body_text(),
905                        "created": c.created,
906                        "updated": c.updated,
907                    })
908                })
909                .collect()
910        })
911        .unwrap_or_default();
912
913    let issue_links: Vec<serde_json::Value> = issue
914        .fields
915        .issue_links
916        .as_deref()
917        .unwrap_or_default()
918        .iter()
919        .map(|link| {
920            let sentence = if let Some(ref out_issue) = link.outward_issue {
921                format!("{} {} {}", issue.key, link.link_type.outward, out_issue.key)
922            } else if let Some(ref in_issue) = link.inward_issue {
923                format!("{} {} {}", issue.key, link.link_type.inward, in_issue.key)
924            } else {
925                String::new()
926            };
927            serde_json::json!({
928                "id": link.id,
929                "sentence": sentence,
930                "type": {
931                    "id": link.link_type.id,
932                    "name": link.link_type.name,
933                    "inward": link.link_type.inward,
934                    "outward": link.link_type.outward,
935                },
936                "outwardIssue": link.outward_issue.as_ref().map(|i| serde_json::json!({
937                    "key": i.key,
938                    "summary": i.fields.summary,
939                    "status": i.fields.status.name,
940                })),
941                "inwardIssue": link.inward_issue.as_ref().map(|i| serde_json::json!({
942                    "key": i.key,
943                    "summary": i.fields.summary,
944                    "status": i.fields.status.name,
945                })),
946            })
947        })
948        .collect();
949
950    serde_json::json!({
951        "key": issue.key,
952        "id": issue.id,
953        "url": client.browse_url(&issue.key),
954        "summary": issue.summary(),
955        "status": issue.status(),
956        "type": issue.issue_type(),
957        "priority": issue.priority(),
958        "assignee": {
959            "displayName": issue.assignee(),
960            "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
961        },
962        "reporter": issue.fields.reporter.as_ref().map(|r| serde_json::json!({
963            "displayName": r.display_name,
964            "accountId": r.account_id,
965        })),
966        "labels": issue.fields.labels,
967        "components": issue.fields.components,
968        "fixVersions": issue.fields.fix_versions.as_ref().map(|fvs| {
969            fvs.iter().map(version_to_json).collect::<Vec<_>>()
970        }),
971        "affectedVersions": issue.fields.versions.as_ref().map(|vs| {
972            vs.iter().map(version_to_json).collect::<Vec<_>>()
973        }),
974        "description": issue.description_text(),
975        "created": issue.fields.created,
976        "updated": issue.fields.updated,
977        "comments": comments,
978        "issueLinks": issue_links,
979    })
980}
981
982// ── Helpers ───────────────────────────────────────────────────────────────────
983
984fn jql_multi_value(field: &str, values: &[&str]) -> Option<String> {
985    match values.len() {
986        0 => None,
987        1 => Some(format!(r#"{field} = "{}""#, escape_jql(values[0]))),
988        _ => {
989            let quoted: Vec<String> = values
990                .iter()
991                .map(|v| format!(r#""{}""#, escape_jql(v)))
992                .collect();
993            Some(format!("{field} in ({})", quoted.join(", ")))
994        }
995    }
996}
997
998fn build_list_jql(filters: &ListFilters<'_>) -> String {
999    let mut parts: Vec<String> = Vec::new();
1000
1001    if let Some(p) = filters.project {
1002        parts.push(format!(r#"project = "{}""#, escape_jql(p)));
1003    }
1004    if let Some(s) = filters.status {
1005        parts.push(format!(r#"status = "{}""#, escape_jql(s)));
1006    }
1007    if let Some(a) = filters.assignee {
1008        if a == "me" {
1009            parts.push("assignee = currentUser()".into());
1010        } else {
1011            parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
1012        }
1013    }
1014    if let Some(t) = filters.issue_type {
1015        parts.push(format!(r#"issuetype = "{}""#, escape_jql(t)));
1016    }
1017    if let Some(s) = filters.sprint {
1018        if s == "active" || s == "open" {
1019            parts.push("sprint in openSprints()".into());
1020        } else {
1021            parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
1022        }
1023    }
1024    if let Some(comps) = filters.components {
1025        parts.extend(jql_multi_value("component", comps));
1026    }
1027    if let Some(lbls) = filters.labels {
1028        parts.extend(jql_multi_value("labels", lbls));
1029    }
1030    if let Some(fvs) = filters.fix_versions {
1031        parts.extend(jql_multi_value("fixVersion", fvs));
1032    }
1033    if let Some(e) = filters.jql_extra {
1034        parts.push(format!("({e})"));
1035    }
1036
1037    if parts.is_empty() {
1038        "ORDER BY updated DESC".into()
1039    } else {
1040        format!("{} ORDER BY updated DESC", parts.join(" AND "))
1041    }
1042}
1043
1044/// Color-code a Jira status string for terminal output.
1045fn colorize_status(status: &str, display: &str) -> String {
1046    let lower = status.to_lowercase();
1047    if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
1048        display.green().to_string()
1049    } else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
1050        display.yellow().to_string()
1051    } else if lower.contains("blocked") || lower.contains("impediment") {
1052        display.red().to_string()
1053    } else {
1054        display.to_string()
1055    }
1056}
1057
1058/// Open a URL in the system default browser, printing a warning if it fails.
1059fn open_in_browser(url: &str) {
1060    #[cfg(target_os = "macos")]
1061    let result = std::process::Command::new("open").arg(url).status();
1062    #[cfg(target_os = "linux")]
1063    let result = std::process::Command::new("xdg-open").arg(url).status();
1064    #[cfg(target_os = "windows")]
1065    let result = std::process::Command::new("cmd")
1066        .args(["/c", "start", url])
1067        .status();
1068
1069    #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
1070    if let Err(e) = result {
1071        eprintln!("Warning: could not open browser: {e}");
1072    }
1073}
1074
1075/// Truncate a string to `max` characters (not bytes), appending `…` if cut.
1076fn truncate(s: &str, max: usize) -> String {
1077    let mut chars = s.chars();
1078    let mut result: String = chars.by_ref().take(max).collect();
1079    if chars.next().is_some() {
1080        result.push('…');
1081    }
1082    result
1083}
1084
1085/// Shorten an ISO-8601 timestamp to just the date portion.
1086fn format_date(s: &str) -> String {
1087    s.chars().take(10).collect()
1088}
1089
1090/// Minimum width to clamp narrow terminals to, so fixed columns (key, status,
1091/// assignee, type) still leave at least 20 characters for the summary.
1092const MIN_TERMINAL_WIDTH: usize = 60;
1093
1094/// Fallback width used when neither the TTY nor `COLUMNS` advertises a size —
1095/// matches the historical default.
1096const DEFAULT_TERMINAL_WIDTH: usize = 120;
1097
1098/// Determine the terminal width for rendering the issues table.
1099///
1100/// Query the live TTY first (via `ioctl(TIOCGWINSZ)` / Windows console APIs),
1101/// fall back to `COLUMNS` for non-TTY contexts where the caller still wants
1102/// to pin the width, and finally to a reasonable default.
1103fn terminal_width() -> usize {
1104    use std::io::IsTerminal;
1105
1106    let tty_width = std::io::stdout()
1107        .is_terminal()
1108        .then(terminal_size::terminal_size)
1109        .flatten()
1110        .map(|(terminal_size::Width(w), _)| w as usize);
1111    let columns = std::env::var("COLUMNS").ok().and_then(|v| v.parse().ok());
1112
1113    resolve_terminal_width(tty_width, columns)
1114}
1115
1116/// Pure resolution of the three width sources, in priority order. Extracted so
1117/// the decision logic is testable without mocking the process environment or
1118/// the TTY.
1119fn resolve_terminal_width(tty_width: Option<usize>, columns: Option<usize>) -> usize {
1120    if let Some(w) = tty_width {
1121        return w.max(MIN_TERMINAL_WIDTH);
1122    }
1123    columns.unwrap_or(DEFAULT_TERMINAL_WIDTH)
1124}
1125
1126/// Resolve a CLI `--assignee` argument into the three-state `IssueUpdate.assignee` value.
1127///
1128/// `--assignee me` triggers a `GET /myself` round-trip to fetch the current user's account ID.
1129/// `--assignee none` returns `Some(None)` (the unassign sentinel).
1130/// `--assignee <id>` returns `Some(Some(id))` (set to the literal account ID).
1131/// `None` (flag absent) returns `None` (leave the field untouched).
1132pub async fn resolve_assignee_arg(
1133    client: &JiraClient,
1134    arg: Option<&str>,
1135) -> Result<Option<Option<String>>, ApiError> {
1136    match arg {
1137        None => Ok(None),
1138        Some("none") => Ok(Some(None)),
1139        Some("me") => {
1140            let me = client.get_myself().await?;
1141            Ok(Some(Some(me.account_id)))
1142        }
1143        Some(id) => Ok(Some(Some(id.to_string()))),
1144    }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    use super::*;
1150    use crate::api::types::{IssueFields, IssueTypeField, StatusField, Version};
1151
1152    fn issue_fixture(fix: Option<Vec<Version>>, aff: Option<Vec<Version>>) -> Issue {
1153        Issue {
1154            id: "10001".into(),
1155            key: "PROJ-1".into(),
1156            url: None,
1157            fields: IssueFields {
1158                summary: "Test".into(),
1159                status: StatusField {
1160                    name: "Open".into(),
1161                },
1162                assignee: None,
1163                reporter: None,
1164                priority: None,
1165                issuetype: IssueTypeField { name: "Bug".into() },
1166                description: None,
1167                labels: None,
1168                components: None,
1169                fix_versions: fix,
1170                versions: aff,
1171                created: None,
1172                updated: None,
1173                comment: None,
1174                issue_links: None,
1175            },
1176        }
1177    }
1178
1179    fn make_version(id: &str, name: &str) -> Version {
1180        Version {
1181            id: id.into(),
1182            name: name.into(),
1183            description: None,
1184            released: None,
1185            archived: None,
1186            release_date: None,
1187        }
1188    }
1189
1190    #[test]
1191    fn write_issue_detail_renders_fix_versions_line() {
1192        let issue = issue_fixture(
1193            Some(vec![make_version("1", "1.2.0"), make_version("2", "1.3.0")]),
1194            None,
1195        );
1196        let mut buf = Vec::new();
1197        write_issue_detail(&mut buf, &issue).unwrap();
1198        let out = String::from_utf8(buf).unwrap();
1199        assert!(
1200            out.contains("  Fix Versions:     1.2.0, 1.3.0"),
1201            "expected rendered fix-versions line, got:\n{out}"
1202        );
1203    }
1204
1205    #[test]
1206    fn write_issue_detail_renders_affects_versions_line() {
1207        let issue = issue_fixture(None, Some(vec![make_version("5", "1.1.0")]));
1208        let mut buf = Vec::new();
1209        write_issue_detail(&mut buf, &issue).unwrap();
1210        let out = String::from_utf8(buf).unwrap();
1211        assert!(
1212            out.contains("  Affects Versions: 1.1.0"),
1213            "expected affects-versions line, got:\n{out}"
1214        );
1215    }
1216
1217    #[test]
1218    fn write_issue_detail_omits_version_lines_when_empty() {
1219        let issue = issue_fixture(Some(vec![]), None);
1220        let mut buf = Vec::new();
1221        write_issue_detail(&mut buf, &issue).unwrap();
1222        let out = String::from_utf8(buf).unwrap();
1223        assert!(
1224            !out.contains("Fix Versions:"),
1225            "should omit fix versions header for empty slice, got:\n{out}"
1226        );
1227        assert!(
1228            !out.contains("Affects Versions:"),
1229            "should omit affects versions header when None, got:\n{out}"
1230        );
1231    }
1232
1233    #[test]
1234    fn truncate_short_string() {
1235        assert_eq!(truncate("hello", 10), "hello");
1236    }
1237
1238    #[test]
1239    fn truncate_exact_length() {
1240        assert_eq!(truncate("hello", 5), "hello");
1241    }
1242
1243    #[test]
1244    fn truncate_long_string() {
1245        assert_eq!(truncate("hello world", 5), "hello…");
1246    }
1247
1248    #[test]
1249    fn truncate_multibyte_safe() {
1250        let result = truncate("日本語テスト", 3);
1251        assert_eq!(result, "日本語…");
1252    }
1253
1254    #[test]
1255    fn build_list_jql_empty() {
1256        assert_eq!(
1257            build_list_jql(&ListFilters::default()),
1258            "ORDER BY updated DESC"
1259        );
1260    }
1261
1262    #[test]
1263    fn build_list_jql_escapes_quotes() {
1264        let jql = build_list_jql(&ListFilters {
1265            status: Some(r#"Done" OR 1=1"#),
1266            ..Default::default()
1267        });
1268        // The double quote must be backslash-escaped so it cannot break out of the JQL string.
1269        // The resulting clause should be:  status = "Done\" OR 1=1"
1270        assert!(jql.contains(r#"\""#), "double quote must be escaped");
1271        assert!(
1272            jql.contains(r#"status = "Done\""#),
1273            "escaped quote must remain inside the status value string"
1274        );
1275    }
1276
1277    #[test]
1278    fn build_list_jql_project_and_status() {
1279        let jql = build_list_jql(&ListFilters {
1280            project: Some("PROJ"),
1281            status: Some("In Progress"),
1282            ..Default::default()
1283        });
1284        assert!(jql.contains(r#"project = "PROJ""#));
1285        assert!(jql.contains(r#"status = "In Progress""#));
1286    }
1287
1288    #[test]
1289    fn build_list_jql_assignee_me() {
1290        let jql = build_list_jql(&ListFilters {
1291            assignee: Some("me"),
1292            ..Default::default()
1293        });
1294        assert!(jql.contains("currentUser()"));
1295    }
1296
1297    #[test]
1298    fn build_list_jql_issue_type() {
1299        let jql = build_list_jql(&ListFilters {
1300            issue_type: Some("Bug"),
1301            ..Default::default()
1302        });
1303        assert!(jql.contains(r#"issuetype = "Bug""#));
1304    }
1305
1306    #[test]
1307    fn build_list_jql_sprint_active() {
1308        let jql = build_list_jql(&ListFilters {
1309            sprint: Some("active"),
1310            ..Default::default()
1311        });
1312        assert!(jql.contains("sprint in openSprints()"));
1313    }
1314
1315    #[test]
1316    fn build_list_jql_sprint_named() {
1317        let jql = build_list_jql(&ListFilters {
1318            sprint: Some("Sprint 42"),
1319            ..Default::default()
1320        });
1321        assert!(jql.contains(r#"sprint = "Sprint 42""#));
1322    }
1323
1324    #[test]
1325    fn build_list_jql_single_component() {
1326        let jql = build_list_jql(&ListFilters {
1327            components: Some(&["Backend"]),
1328            ..Default::default()
1329        });
1330        assert!(
1331            jql.contains(r#"component = "Backend""#),
1332            "expected single-component clause, got: {jql}"
1333        );
1334    }
1335
1336    #[test]
1337    fn build_list_jql_multiple_components() {
1338        let jql = build_list_jql(&ListFilters {
1339            components: Some(&["Backend", "API"]),
1340            ..Default::default()
1341        });
1342        assert!(
1343            jql.contains(r#"component in ("Backend", "API")"#),
1344            "expected `component in (...)` clause, got: {jql}"
1345        );
1346    }
1347
1348    #[test]
1349    fn build_list_jql_escapes_component_quotes() {
1350        let jql = build_list_jql(&ListFilters {
1351            components: Some(&[r#"weird "name""#]),
1352            ..Default::default()
1353        });
1354        assert!(
1355            jql.contains(r#"component = "weird \"name\"""#),
1356            "expected escaped quotes, got: {jql}"
1357        );
1358    }
1359
1360    #[test]
1361    fn build_list_jql_empty_components_emits_no_clause() {
1362        let jql = build_list_jql(&ListFilters {
1363            components: Some(&[]),
1364            ..Default::default()
1365        });
1366        assert!(
1367            !jql.contains("component"),
1368            "expected no component clause for empty slice, got: {jql}"
1369        );
1370    }
1371
1372    #[test]
1373    fn build_list_jql_single_label() {
1374        let jql = build_list_jql(&ListFilters {
1375            labels: Some(&["backend"]),
1376            ..Default::default()
1377        });
1378        assert!(
1379            jql.contains(r#"labels = "backend""#),
1380            "expected single-label clause, got: {jql}"
1381        );
1382    }
1383
1384    #[test]
1385    fn build_list_jql_multiple_labels() {
1386        let jql = build_list_jql(&ListFilters {
1387            labels: Some(&["backend", "urgent"]),
1388            ..Default::default()
1389        });
1390        assert!(
1391            jql.contains(r#"labels in ("backend", "urgent")"#),
1392            "expected `labels in (...)` clause, got: {jql}"
1393        );
1394    }
1395
1396    #[test]
1397    fn build_list_jql_escapes_label_quotes() {
1398        let jql = build_list_jql(&ListFilters {
1399            labels: Some(&[r#"weird "name""#]),
1400            ..Default::default()
1401        });
1402        assert!(
1403            jql.contains(r#"labels = "weird \"name\"""#),
1404            "expected escaped quotes, got: {jql}"
1405        );
1406    }
1407
1408    #[test]
1409    fn build_list_jql_empty_labels_emits_no_clause() {
1410        let jql = build_list_jql(&ListFilters {
1411            labels: Some(&[]),
1412            ..Default::default()
1413        });
1414        assert!(
1415            !jql.contains("labels"),
1416            "expected no labels clause for empty slice, got: {jql}"
1417        );
1418    }
1419
1420    #[test]
1421    fn build_list_jql_single_fix_version() {
1422        let jql = build_list_jql(&ListFilters {
1423            fix_versions: Some(&["1.2.0"]),
1424            ..Default::default()
1425        });
1426        assert!(
1427            jql.contains(r#"fixVersion = "1.2.0""#),
1428            "expected single fixVersion clause, got: {jql}"
1429        );
1430    }
1431
1432    #[test]
1433    fn build_list_jql_multiple_fix_versions() {
1434        let jql = build_list_jql(&ListFilters {
1435            fix_versions: Some(&["1.2.0", "1.3.0"]),
1436            ..Default::default()
1437        });
1438        assert!(
1439            jql.contains(r#"fixVersion in ("1.2.0", "1.3.0")"#),
1440            "expected fixVersion in (...) clause, got: {jql}"
1441        );
1442    }
1443
1444    #[test]
1445    fn build_list_jql_escapes_fix_version_quotes() {
1446        let jql = build_list_jql(&ListFilters {
1447            fix_versions: Some(&[r#"weird "ver""#]),
1448            ..Default::default()
1449        });
1450        assert!(
1451            jql.contains(r#"fixVersion = "weird \"ver\"""#),
1452            "expected escaped quotes, got: {jql}"
1453        );
1454    }
1455
1456    #[test]
1457    fn build_list_jql_empty_fix_versions_emits_no_clause() {
1458        let jql = build_list_jql(&ListFilters {
1459            fix_versions: Some(&[]),
1460            ..Default::default()
1461        });
1462        assert!(
1463            !jql.contains("fixVersion"),
1464            "expected no fixVersion clause for empty slice, got: {jql}"
1465        );
1466    }
1467
1468    #[test]
1469    fn colorize_status_done_is_green() {
1470        let result = colorize_status("Done", "Done");
1471        assert!(result.contains("Done"));
1472        // Green ANSI escape code starts with \x1b[32m
1473        assert!(result.contains("\x1b["));
1474    }
1475
1476    #[test]
1477    fn colorize_status_unknown_unchanged() {
1478        let result = colorize_status("Backlog", "Backlog");
1479        assert_eq!(result, "Backlog");
1480    }
1481
1482    /// Ensures an environment variable is removed even if the test panics.
1483    struct EnvVarGuard(&'static str);
1484
1485    impl Drop for EnvVarGuard {
1486        fn drop(&mut self) {
1487            unsafe { std::env::remove_var(self.0) }
1488        }
1489    }
1490
1491    #[test]
1492    fn terminal_width_fallback_parses_columns() {
1493        unsafe { std::env::set_var("COLUMNS", "200") };
1494        let _guard = EnvVarGuard("COLUMNS");
1495        assert_eq!(terminal_width(), 200);
1496    }
1497
1498    #[test]
1499    fn resolve_terminal_width_prefers_tty_over_columns() {
1500        assert_eq!(resolve_terminal_width(Some(200), Some(80)), 200);
1501    }
1502
1503    #[test]
1504    fn resolve_terminal_width_clamps_narrow_tty_to_minimum() {
1505        assert_eq!(resolve_terminal_width(Some(40), None), MIN_TERMINAL_WIDTH);
1506    }
1507
1508    #[test]
1509    fn resolve_terminal_width_does_not_clamp_columns_fallback() {
1510        // Users who explicitly pin COLUMNS (e.g. for non-TTY output or tests)
1511        // get exactly what they asked for; only the TTY-measured width is
1512        // clamped.
1513        assert_eq!(resolve_terminal_width(None, Some(40)), 40);
1514    }
1515
1516    #[test]
1517    fn resolve_terminal_width_defaults_when_nothing_available() {
1518        assert_eq!(resolve_terminal_width(None, None), DEFAULT_TERMINAL_WIDTH);
1519    }
1520
1521    #[tokio::test]
1522    async fn resolve_assignee_arg_absent_returns_none() {
1523        let server = wiremock::MockServer::start().await;
1524        let client = crate::api::JiraClient::new(
1525            &server.uri(),
1526            "test@example.com",
1527            "test-token",
1528            crate::api::AuthType::Basic,
1529            3,
1530        )
1531        .unwrap();
1532        let result = resolve_assignee_arg(&client, None).await.unwrap();
1533        assert!(result.is_none());
1534    }
1535
1536    #[tokio::test]
1537    async fn resolve_assignee_arg_none_sentinel_returns_some_none() {
1538        let server = wiremock::MockServer::start().await;
1539        let client = crate::api::JiraClient::new(
1540            &server.uri(),
1541            "test@example.com",
1542            "test-token",
1543            crate::api::AuthType::Basic,
1544            3,
1545        )
1546        .unwrap();
1547        let result = resolve_assignee_arg(&client, Some("none")).await.unwrap();
1548        assert!(matches!(result, Some(None)));
1549    }
1550
1551    #[tokio::test]
1552    async fn resolve_assignee_arg_literal_id_passes_through() {
1553        let server = wiremock::MockServer::start().await;
1554        let client = crate::api::JiraClient::new(
1555            &server.uri(),
1556            "test@example.com",
1557            "test-token",
1558            crate::api::AuthType::Basic,
1559            3,
1560        )
1561        .unwrap();
1562        let result = resolve_assignee_arg(&client, Some("literal-id-999"))
1563            .await
1564            .unwrap();
1565        assert_eq!(result, Some(Some("literal-id-999".to_string())));
1566    }
1567}