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