Skip to main content

jira_cli/commands/
issues.rs

1use owo_colors::OwoColorize;
2
3use crate::api::{ApiError, Issue, IssueLink, JiraClient, escape_jql};
4use crate::output::{OutputConfig, use_color};
5
6#[allow(clippy::too_many_arguments)]
7pub async fn list(
8    client: &JiraClient,
9    out: &OutputConfig,
10    project: Option<&str>,
11    status: Option<&str>,
12    assignee: Option<&str>,
13    issue_type: Option<&str>,
14    sprint: Option<&str>,
15    jql_extra: Option<&str>,
16    limit: usize,
17    offset: usize,
18    all: bool,
19) -> Result<(), ApiError> {
20    let jql = build_list_jql(project, status, assignee, issue_type, sprint, jql_extra);
21    if all {
22        let issues = fetch_all_issues(client, &jql).await?;
23        render_results(out, &issues, issues.len(), 0, issues.len(), client, false);
24    } else {
25        let resp = client.search(&jql, limit, offset).await?;
26        let more = resp.total > resp.start_at + resp.issues.len();
27        render_results(
28            out,
29            &resp.issues,
30            resp.total,
31            resp.start_at,
32            resp.max_results,
33            client,
34            more,
35        );
36    }
37    Ok(())
38}
39
40/// List issues assigned to the current user.
41#[allow(clippy::too_many_arguments)]
42pub async fn mine(
43    client: &JiraClient,
44    out: &OutputConfig,
45    project: Option<&str>,
46    status: Option<&str>,
47    issue_type: Option<&str>,
48    sprint: Option<&str>,
49    limit: usize,
50    all: bool,
51) -> Result<(), ApiError> {
52    list(
53        client,
54        out,
55        project,
56        status,
57        Some("me"),
58        issue_type,
59        sprint,
60        None,
61        limit,
62        0,
63        all,
64    )
65    .await
66}
67
68/// List comments on an issue.
69pub async fn comments(client: &JiraClient, out: &OutputConfig, key: &str) -> Result<(), ApiError> {
70    let issue = client.get_issue(key).await?;
71    let comment_list = issue.fields.comment.as_ref();
72
73    if out.json {
74        let comments_json: Vec<serde_json::Value> = comment_list
75            .map(|cl| {
76                cl.comments
77                    .iter()
78                    .map(|c| {
79                        serde_json::json!({
80                            "id": c.id,
81                            "author": {
82                                "displayName": c.author.display_name,
83                                "accountId": c.author.account_id,
84                            },
85                            "body": c.body_text(),
86                            "created": c.created,
87                            "updated": c.updated,
88                        })
89                    })
90                    .collect()
91            })
92            .unwrap_or_default();
93        let total = comment_list.map(|cl| cl.total).unwrap_or(0);
94        out.print_data(
95            &serde_json::to_string_pretty(&serde_json::json!({
96                "issue": key,
97                "total": total,
98                "comments": comments_json,
99            }))
100            .expect("failed to serialize JSON"),
101        );
102    } else {
103        match comment_list {
104            None => {
105                out.print_message(&format!("No comments on {key}."));
106            }
107            Some(cl) if cl.comments.is_empty() => {
108                out.print_message(&format!("No comments on {key}."));
109            }
110            Some(cl) => {
111                let color = use_color();
112                out.print_message(&format!("Comments on {key} ({}):", cl.total));
113                for c in &cl.comments {
114                    println!();
115                    let author = if color {
116                        c.author.display_name.bold().to_string()
117                    } else {
118                        c.author.display_name.clone()
119                    };
120                    println!("  {} — {}", author, format_date(&c.created));
121                    for line in c.body_text().lines() {
122                        println!("    {line}");
123                    }
124                }
125            }
126        }
127    }
128    Ok(())
129}
130
131/// Fetch every page of a JQL search, returning all issues.
132pub async fn fetch_all_issues(client: &JiraClient, jql: &str) -> Result<Vec<Issue>, ApiError> {
133    const PAGE_SIZE: usize = 100;
134    let mut all: Vec<Issue> = Vec::new();
135    let mut offset = 0;
136    loop {
137        let resp = client.search(jql, PAGE_SIZE, offset).await?;
138        let fetched = resp.issues.len();
139        all.extend(resp.issues);
140        offset += fetched;
141        if offset >= resp.total || fetched == 0 {
142            break;
143        }
144    }
145    Ok(all)
146}
147
148fn render_results(
149    out: &OutputConfig,
150    issues: &[Issue],
151    total: usize,
152    start_at: usize,
153    max_results: usize,
154    client: &JiraClient,
155    more: bool,
156) {
157    if out.json {
158        out.print_data(
159            &serde_json::to_string_pretty(&serde_json::json!({
160                "total": total,
161                "startAt": start_at,
162                "maxResults": max_results,
163                "issues": issues.iter().map(|i| issue_to_json(i, client)).collect::<Vec<_>>(),
164            }))
165            .expect("failed to serialize JSON"),
166        );
167    } else {
168        render_issue_table(issues, out);
169        if more {
170            out.print_message(&format!(
171                "Showing {}-{} of {} issues — use --limit/--offset or --all to paginate",
172                start_at + 1,
173                start_at + issues.len(),
174                total
175            ));
176        } else {
177            out.print_message(&format!("{} issues", issues.len()));
178        }
179    }
180}
181
182pub async fn show(
183    client: &JiraClient,
184    out: &OutputConfig,
185    key: &str,
186    open: bool,
187) -> Result<(), ApiError> {
188    let issue = client.get_issue(key).await?;
189
190    if open {
191        open_in_browser(&client.browse_url(&issue.key));
192    }
193
194    if out.json {
195        out.print_data(
196            &serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
197                .expect("failed to serialize JSON"),
198        );
199    } else {
200        render_issue_detail(&issue);
201    }
202    Ok(())
203}
204
205#[allow(clippy::too_many_arguments)]
206pub async fn create(
207    client: &JiraClient,
208    out: &OutputConfig,
209    project: &str,
210    issue_type: &str,
211    summary: &str,
212    description: Option<&str>,
213    priority: Option<&str>,
214    labels: Option<&[&str]>,
215    assignee: Option<&str>,
216    sprint: Option<&str>,
217    parent: Option<&str>,
218    custom_fields: &[(String, serde_json::Value)],
219) -> Result<(), ApiError> {
220    let resp = client
221        .create_issue(
222            project,
223            issue_type,
224            summary,
225            description,
226            priority,
227            labels,
228            assignee,
229            parent,
230            custom_fields,
231        )
232        .await?;
233    let url = client.browse_url(&resp.key);
234
235    let mut result = serde_json::json!({ "key": resp.key, "id": resp.id, "url": url });
236    if let Some(p) = parent {
237        result["parent"] = serde_json::json!(p);
238    }
239    if let Some(s) = sprint {
240        let resolved = client.resolve_sprint(s).await?;
241        client.move_issue_to_sprint(&resp.key, resolved.id).await?;
242        result["sprintId"] = serde_json::json!(resolved.id);
243        result["sprintName"] = serde_json::json!(resolved.name);
244    }
245    out.print_result(&result, &resp.key);
246    Ok(())
247}
248
249pub async fn update(
250    client: &JiraClient,
251    out: &OutputConfig,
252    key: &str,
253    summary: Option<&str>,
254    description: Option<&str>,
255    priority: Option<&str>,
256    custom_fields: &[(String, serde_json::Value)],
257) -> Result<(), ApiError> {
258    client
259        .update_issue(key, summary, description, priority, custom_fields)
260        .await?;
261    out.print_result(
262        &serde_json::json!({ "key": key, "updated": true }),
263        &format!("Updated {key}"),
264    );
265    Ok(())
266}
267
268/// Move an issue to a sprint.
269pub async fn move_to_sprint(
270    client: &JiraClient,
271    out: &OutputConfig,
272    key: &str,
273    sprint: &str,
274) -> Result<(), ApiError> {
275    let resolved = client.resolve_sprint(sprint).await?;
276    client.move_issue_to_sprint(key, resolved.id).await?;
277    out.print_result(
278        &serde_json::json!({
279            "issue": key,
280            "sprintId": resolved.id,
281            "sprintName": resolved.name,
282        }),
283        &format!("Moved {key} to {} ({})", resolved.name, resolved.id),
284    );
285    Ok(())
286}
287
288pub async fn comment(
289    client: &JiraClient,
290    out: &OutputConfig,
291    key: &str,
292    body: &str,
293) -> Result<(), ApiError> {
294    let c = client.add_comment(key, body).await?;
295    let url = client.browse_url(key);
296    out.print_result(
297        &serde_json::json!({
298            "id": c.id,
299            "issue": key,
300            "url": url,
301            "author": c.author.display_name,
302            "created": c.created,
303        }),
304        &format!("Comment added to {key}"),
305    );
306    Ok(())
307}
308
309pub async fn transition(
310    client: &JiraClient,
311    out: &OutputConfig,
312    key: &str,
313    to: &str,
314) -> Result<(), ApiError> {
315    let transitions = client.get_transitions(key).await?;
316
317    let matched = transitions
318        .iter()
319        .find(|t| t.name.to_lowercase() == to.to_lowercase() || t.id == to);
320
321    match matched {
322        Some(t) => {
323            let name = t.name.clone();
324            let id = t.id.clone();
325            let status =
326                t.to.as_ref()
327                    .map(|tt| tt.name.clone())
328                    .unwrap_or_else(|| name.clone());
329            client.do_transition(key, &id).await?;
330            out.print_result(
331                &serde_json::json!({ "issue": key, "transition": name, "status": status, "id": id }),
332                &format!("Transitioned {key} → {status}"),
333            );
334        }
335        None => {
336            let hint = transitions
337                .iter()
338                .map(|t| format!("  {} ({})", t.name, t.id))
339                .collect::<Vec<_>>()
340                .join("\n");
341            out.print_message(&format!(
342                "Transition '{to}' not found for {key}. Available:\n{hint}"
343            ));
344            out.print_message(&format!(
345                "Tip: `jira issues list-transitions {key}` shows transitions as JSON."
346            ));
347            return Err(ApiError::NotFound(format!(
348                "Transition '{to}' not found for {key}"
349            )));
350        }
351    }
352    Ok(())
353}
354
355pub async fn list_transitions(
356    client: &JiraClient,
357    out: &OutputConfig,
358    key: &str,
359) -> Result<(), ApiError> {
360    let ts = client.get_transitions(key).await?;
361
362    if out.json {
363        out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
364    } else {
365        let color = use_color();
366        let header = format!("{:<6} {}", "ID", "Name");
367        if color {
368            println!("{}", header.bold());
369        } else {
370            println!("{header}");
371        }
372        for t in &ts {
373            println!("{:<6} {}", t.id, t.name);
374        }
375    }
376    Ok(())
377}
378
379pub async fn assign(
380    client: &JiraClient,
381    out: &OutputConfig,
382    key: &str,
383    assignee: &str,
384) -> Result<(), ApiError> {
385    let account_id = if assignee == "me" {
386        let me = client.get_myself().await?;
387        me.account_id
388    } else if assignee == "none" || assignee == "unassign" {
389        client.assign_issue(key, None).await?;
390        out.print_result(
391            &serde_json::json!({ "issue": key, "assignee": null }),
392            &format!("Unassigned {key}"),
393        );
394        return Ok(());
395    } else {
396        assignee.to_string()
397    };
398
399    client.assign_issue(key, Some(&account_id)).await?;
400    out.print_result(
401        &serde_json::json!({ "issue": key, "accountId": account_id }),
402        &format!("Assigned {key} to {assignee}"),
403    );
404    Ok(())
405}
406
407/// List available issue link types.
408pub async fn link_types(client: &JiraClient, out: &OutputConfig) -> Result<(), ApiError> {
409    let types = client.get_link_types().await?;
410
411    if out.json {
412        out.print_data(
413            &serde_json::to_string_pretty(&serde_json::json!(
414                types
415                    .iter()
416                    .map(|t| serde_json::json!({
417                        "id": t.id,
418                        "name": t.name,
419                        "inward": t.inward,
420                        "outward": t.outward,
421                    }))
422                    .collect::<Vec<_>>()
423            ))
424            .expect("failed to serialize JSON"),
425        );
426        return Ok(());
427    }
428
429    for t in &types {
430        println!(
431            "{:<20}  outward: {}  /  inward: {}",
432            t.name, t.outward, t.inward
433        );
434    }
435    Ok(())
436}
437
438/// Link two issues.
439pub async fn link(
440    client: &JiraClient,
441    out: &OutputConfig,
442    from_key: &str,
443    to_key: &str,
444    link_type: &str,
445) -> Result<(), ApiError> {
446    client.link_issues(from_key, to_key, link_type).await?;
447    out.print_result(
448        &serde_json::json!({
449            "from": from_key,
450            "to": to_key,
451            "type": link_type,
452        }),
453        &format!("Linked {from_key} → {to_key} ({link_type})"),
454    );
455    Ok(())
456}
457
458/// Remove an issue link by link ID.
459pub async fn unlink(
460    client: &JiraClient,
461    out: &OutputConfig,
462    link_id: &str,
463) -> Result<(), ApiError> {
464    client.unlink_issues(link_id).await?;
465    out.print_result(
466        &serde_json::json!({ "linkId": link_id }),
467        &format!("Removed link {link_id}"),
468    );
469    Ok(())
470}
471
472/// Log work (time) on an issue.
473pub async fn log_work(
474    client: &JiraClient,
475    out: &OutputConfig,
476    key: &str,
477    time_spent: &str,
478    comment: Option<&str>,
479    started: Option<&str>,
480) -> Result<(), ApiError> {
481    let entry = client.log_work(key, time_spent, comment, started).await?;
482    out.print_result(
483        &serde_json::json!({
484            "id": entry.id,
485            "issue": key,
486            "timeSpent": entry.time_spent,
487            "timeSpentSeconds": entry.time_spent_seconds,
488            "author": entry.author.display_name,
489            "started": entry.started,
490            "created": entry.created,
491        }),
492        &format!("Logged {} on {key}", entry.time_spent),
493    );
494    Ok(())
495}
496
497/// Transition all issues matching a JQL query to a new status.
498pub async fn bulk_transition(
499    client: &JiraClient,
500    out: &OutputConfig,
501    jql: &str,
502    to: &str,
503    dry_run: bool,
504) -> Result<(), ApiError> {
505    let issues = fetch_all_issues(client, jql).await?;
506
507    if issues.is_empty() {
508        out.print_message("No issues matched the query.");
509        return Ok(());
510    }
511
512    let mut results: Vec<serde_json::Value> = Vec::new();
513    let mut succeeded = 0usize;
514    let mut failed = 0usize;
515
516    for issue in &issues {
517        if dry_run {
518            results.push(serde_json::json!({
519                "key": issue.key,
520                "status": issue.status(),
521                "action": "would transition",
522                "to": to,
523            }));
524            continue;
525        }
526
527        let transitions = client.get_transitions(&issue.key).await?;
528        let matched = transitions.iter().find(|t| {
529            t.name.eq_ignore_ascii_case(to)
530                || t.to
531                    .as_ref()
532                    .is_some_and(|tt| tt.name.eq_ignore_ascii_case(to))
533                || t.id == to
534        });
535
536        match matched {
537            Some(t) => match client.do_transition(&issue.key, &t.id).await {
538                Ok(()) => {
539                    succeeded += 1;
540                    results.push(serde_json::json!({
541                        "key": issue.key,
542                        "from": issue.status(),
543                        "to": to,
544                        "ok": true,
545                    }));
546                }
547                Err(e) => {
548                    failed += 1;
549                    results.push(serde_json::json!({
550                        "key": issue.key,
551                        "ok": false,
552                        "error": e.to_string(),
553                    }));
554                }
555            },
556            None => {
557                failed += 1;
558                results.push(serde_json::json!({
559                    "key": issue.key,
560                    "ok": false,
561                    "error": format!("transition '{to}' not available"),
562                }));
563            }
564        }
565    }
566
567    if out.json {
568        out.print_data(
569            &serde_json::to_string_pretty(&serde_json::json!({
570                "dryRun": dry_run,
571                "total": issues.len(),
572                "succeeded": succeeded,
573                "failed": failed,
574                "issues": results,
575            }))
576            .expect("failed to serialize JSON"),
577        );
578    } else if dry_run {
579        render_issue_table(&issues, out);
580        out.print_message(&format!(
581            "Dry run: {} issues would be transitioned to '{to}'",
582            issues.len()
583        ));
584    } else {
585        out.print_message(&format!(
586            "Transitioned {succeeded}/{} issues to '{to}'{}",
587            issues.len(),
588            if failed > 0 {
589                format!(" ({failed} failed)")
590            } else {
591                String::new()
592            }
593        ));
594    }
595    Ok(())
596}
597
598/// Assign all issues matching a JQL query to a user.
599pub async fn bulk_assign(
600    client: &JiraClient,
601    out: &OutputConfig,
602    jql: &str,
603    assignee: &str,
604    dry_run: bool,
605) -> Result<(), ApiError> {
606    // Resolve "me" once before the loop.
607    let account_id: Option<String> = match assignee {
608        "me" => {
609            let me = client.get_myself().await?;
610            Some(me.account_id)
611        }
612        "none" | "unassign" => None,
613        id => Some(id.to_string()),
614    };
615
616    let issues = fetch_all_issues(client, jql).await?;
617
618    if issues.is_empty() {
619        out.print_message("No issues matched the query.");
620        return Ok(());
621    }
622
623    let mut results: Vec<serde_json::Value> = Vec::new();
624    let mut succeeded = 0usize;
625    let mut failed = 0usize;
626
627    for issue in &issues {
628        if dry_run {
629            results.push(serde_json::json!({
630                "key": issue.key,
631                "currentAssignee": issue.assignee(),
632                "action": "would assign",
633                "to": assignee,
634            }));
635            continue;
636        }
637
638        match client.assign_issue(&issue.key, account_id.as_deref()).await {
639            Ok(()) => {
640                succeeded += 1;
641                results.push(serde_json::json!({
642                    "key": issue.key,
643                    "assignee": assignee,
644                    "ok": true,
645                }));
646            }
647            Err(e) => {
648                failed += 1;
649                results.push(serde_json::json!({
650                    "key": issue.key,
651                    "ok": false,
652                    "error": e.to_string(),
653                }));
654            }
655        }
656    }
657
658    if out.json {
659        out.print_data(
660            &serde_json::to_string_pretty(&serde_json::json!({
661                "dryRun": dry_run,
662                "total": issues.len(),
663                "succeeded": succeeded,
664                "failed": failed,
665                "issues": results,
666            }))
667            .expect("failed to serialize JSON"),
668        );
669    } else if dry_run {
670        render_issue_table(&issues, out);
671        out.print_message(&format!(
672            "Dry run: {} issues would be assigned to '{assignee}'",
673            issues.len()
674        ));
675    } else {
676        out.print_message(&format!(
677            "Assigned {succeeded}/{} issues to '{assignee}'{}",
678            issues.len(),
679            if failed > 0 {
680                format!(" ({failed} failed)")
681            } else {
682                String::new()
683            }
684        ));
685    }
686    Ok(())
687}
688
689// ── Rendering ─────────────────────────────────────────────────────────────────
690
691pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
692    if issues.is_empty() {
693        out.print_message("No issues found.");
694        return;
695    }
696
697    let color = use_color();
698    let term_width = terminal_width();
699
700    let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
701    let status_w = issues
702        .iter()
703        .map(|i| i.status().len())
704        .max()
705        .unwrap_or(6)
706        .clamp(6, 14)
707        + 2;
708    let assignee_w = issues
709        .iter()
710        .map(|i| i.assignee().len())
711        .max()
712        .unwrap_or(8)
713        .clamp(8, 18)
714        + 2;
715    let type_w = issues
716        .iter()
717        .map(|i| i.issue_type().len())
718        .max()
719        .unwrap_or(4)
720        .clamp(4, 12)
721        + 2;
722
723    // Give remaining width to summary, minimum 20
724    let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
725    let summary_w = term_width.saturating_sub(fixed).max(20);
726
727    let header = format!(
728        "{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
729        "Key", "Status", "Assignee", "Type", "Summary"
730    );
731    if color {
732        println!("{}", header.bold());
733    } else {
734        println!("{header}");
735    }
736
737    for issue in issues {
738        let key = if color {
739            format!("{:<key_w$}", issue.key).yellow().to_string()
740        } else {
741            format!("{:<key_w$}", issue.key)
742        };
743        let status_val = truncate(issue.status(), status_w - 2);
744        let status = if color {
745            colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
746        } else {
747            format!("{:<status_w$}", status_val)
748        };
749        println!(
750            "{key} {status} {:<assignee_w$} {:<type_w$} {}",
751            truncate(issue.assignee(), assignee_w - 2),
752            truncate(issue.issue_type(), type_w - 2),
753            truncate(issue.summary(), summary_w),
754        );
755    }
756}
757
758fn render_issue_detail(issue: &Issue) {
759    let color = use_color();
760    let key = if color {
761        issue.key.yellow().bold().to_string()
762    } else {
763        issue.key.clone()
764    };
765    println!("{key}  {}", issue.summary());
766    println!();
767    println!("  Type:     {}", issue.issue_type());
768    let status_str = if color {
769        colorize_status(issue.status(), issue.status())
770    } else {
771        issue.status().to_string()
772    };
773    println!("  Status:   {status_str}");
774    println!("  Priority: {}", issue.priority());
775    println!("  Assignee: {}", issue.assignee());
776    if let Some(ref reporter) = issue.fields.reporter {
777        println!("  Reporter: {}", reporter.display_name);
778    }
779    if let Some(ref labels) = issue.fields.labels
780        && !labels.is_empty()
781    {
782        println!("  Labels:   {}", labels.join(", "));
783    }
784    if let Some(ref created) = issue.fields.created {
785        println!("  Created:  {}", format_date(created));
786    }
787    if let Some(ref updated) = issue.fields.updated {
788        println!("  Updated:  {}", format_date(updated));
789    }
790
791    let desc = issue.description_text();
792    if !desc.is_empty() {
793        println!();
794        println!("Description:");
795        for line in desc.lines() {
796            println!("  {line}");
797        }
798    }
799
800    if let Some(ref links) = issue.fields.issue_links
801        && !links.is_empty()
802    {
803        println!();
804        println!("Links:");
805        for link in links {
806            render_issue_link(link);
807        }
808    }
809
810    if let Some(ref comment_list) = issue.fields.comment
811        && !comment_list.comments.is_empty()
812    {
813        println!();
814        println!("Comments ({}):", comment_list.total);
815        for c in &comment_list.comments {
816            println!();
817            let author = if color {
818                c.author.display_name.bold().to_string()
819            } else {
820                c.author.display_name.clone()
821            };
822            println!("  {} — {}", author, format_date(&c.created));
823            let body = c.body_text();
824            for line in body.lines() {
825                println!("    {line}");
826            }
827        }
828    }
829}
830
831fn render_issue_link(link: &IssueLink) {
832    if let Some(ref out_issue) = link.outward_issue {
833        println!(
834            "  [{}] {} {} — {}",
835            link.id, link.link_type.outward, out_issue.key, out_issue.fields.summary
836        );
837    }
838    if let Some(ref in_issue) = link.inward_issue {
839        println!(
840            "  [{}] {} {} — {}",
841            link.id, link.link_type.inward, in_issue.key, in_issue.fields.summary
842        );
843    }
844}
845
846// ── JSON serialization ────────────────────────────────────────────────────────
847
848pub(crate) fn issue_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
849    serde_json::json!({
850        "key": issue.key,
851        "id": issue.id,
852        "url": client.browse_url(&issue.key),
853        "summary": issue.summary(),
854        "status": issue.status(),
855        "assignee": {
856            "displayName": issue.assignee(),
857            "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
858        },
859        "priority": issue.priority(),
860        "type": issue.issue_type(),
861        "created": issue.fields.created,
862        "updated": issue.fields.updated,
863    })
864}
865
866fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
867    let comments: Vec<serde_json::Value> = issue
868        .fields
869        .comment
870        .as_ref()
871        .map(|cl| {
872            cl.comments
873                .iter()
874                .map(|c| {
875                    serde_json::json!({
876                        "id": c.id,
877                        "author": {
878                            "displayName": c.author.display_name,
879                            "accountId": c.author.account_id,
880                        },
881                        "body": c.body_text(),
882                        "created": c.created,
883                        "updated": c.updated,
884                    })
885                })
886                .collect()
887        })
888        .unwrap_or_default();
889
890    let issue_links: Vec<serde_json::Value> = issue
891        .fields
892        .issue_links
893        .as_deref()
894        .unwrap_or_default()
895        .iter()
896        .map(|link| {
897            let sentence = if let Some(ref out_issue) = link.outward_issue {
898                format!("{} {} {}", issue.key, link.link_type.outward, out_issue.key)
899            } else if let Some(ref in_issue) = link.inward_issue {
900                format!("{} {} {}", issue.key, link.link_type.inward, in_issue.key)
901            } else {
902                String::new()
903            };
904            serde_json::json!({
905                "id": link.id,
906                "sentence": sentence,
907                "type": {
908                    "id": link.link_type.id,
909                    "name": link.link_type.name,
910                    "inward": link.link_type.inward,
911                    "outward": link.link_type.outward,
912                },
913                "outwardIssue": link.outward_issue.as_ref().map(|i| serde_json::json!({
914                    "key": i.key,
915                    "summary": i.fields.summary,
916                    "status": i.fields.status.name,
917                })),
918                "inwardIssue": link.inward_issue.as_ref().map(|i| serde_json::json!({
919                    "key": i.key,
920                    "summary": i.fields.summary,
921                    "status": i.fields.status.name,
922                })),
923            })
924        })
925        .collect();
926
927    serde_json::json!({
928        "key": issue.key,
929        "id": issue.id,
930        "url": client.browse_url(&issue.key),
931        "summary": issue.summary(),
932        "status": issue.status(),
933        "type": issue.issue_type(),
934        "priority": issue.priority(),
935        "assignee": {
936            "displayName": issue.assignee(),
937            "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
938        },
939        "reporter": issue.fields.reporter.as_ref().map(|r| serde_json::json!({
940            "displayName": r.display_name,
941            "accountId": r.account_id,
942        })),
943        "labels": issue.fields.labels,
944        "description": issue.description_text(),
945        "created": issue.fields.created,
946        "updated": issue.fields.updated,
947        "comments": comments,
948        "issueLinks": issue_links,
949    })
950}
951
952// ── Helpers ───────────────────────────────────────────────────────────────────
953
954fn build_list_jql(
955    project: Option<&str>,
956    status: Option<&str>,
957    assignee: Option<&str>,
958    issue_type: Option<&str>,
959    sprint: Option<&str>,
960    extra: Option<&str>,
961) -> String {
962    let mut parts: Vec<String> = Vec::new();
963
964    if let Some(p) = project {
965        parts.push(format!(r#"project = "{}""#, escape_jql(p)));
966    }
967    if let Some(s) = status {
968        parts.push(format!(r#"status = "{}""#, escape_jql(s)));
969    }
970    if let Some(a) = assignee {
971        if a == "me" {
972            parts.push("assignee = currentUser()".into());
973        } else {
974            parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
975        }
976    }
977    if let Some(t) = issue_type {
978        parts.push(format!(r#"issuetype = "{}""#, escape_jql(t)));
979    }
980    if let Some(s) = sprint {
981        if s == "active" || s == "open" {
982            parts.push("sprint in openSprints()".into());
983        } else {
984            parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
985        }
986    }
987    if let Some(e) = extra {
988        parts.push(format!("({e})"));
989    }
990
991    if parts.is_empty() {
992        "ORDER BY updated DESC".into()
993    } else {
994        format!("{} ORDER BY updated DESC", parts.join(" AND "))
995    }
996}
997
998/// Color-code a Jira status string for terminal output.
999fn colorize_status(status: &str, display: &str) -> String {
1000    let lower = status.to_lowercase();
1001    if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
1002        display.green().to_string()
1003    } else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
1004        display.yellow().to_string()
1005    } else if lower.contains("blocked") || lower.contains("impediment") {
1006        display.red().to_string()
1007    } else {
1008        display.to_string()
1009    }
1010}
1011
1012/// Open a URL in the system default browser, printing a warning if it fails.
1013fn open_in_browser(url: &str) {
1014    #[cfg(target_os = "macos")]
1015    let result = std::process::Command::new("open").arg(url).status();
1016    #[cfg(target_os = "linux")]
1017    let result = std::process::Command::new("xdg-open").arg(url).status();
1018    #[cfg(target_os = "windows")]
1019    let result = std::process::Command::new("cmd")
1020        .args(["/c", "start", url])
1021        .status();
1022
1023    #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
1024    if let Err(e) = result {
1025        eprintln!("Warning: could not open browser: {e}");
1026    }
1027}
1028
1029/// Truncate a string to `max` characters (not bytes), appending `…` if cut.
1030fn truncate(s: &str, max: usize) -> String {
1031    let mut chars = s.chars();
1032    let mut result: String = chars.by_ref().take(max).collect();
1033    if chars.next().is_some() {
1034        result.push('…');
1035    }
1036    result
1037}
1038
1039/// Shorten an ISO-8601 timestamp to just the date portion.
1040fn format_date(s: &str) -> String {
1041    s.chars().take(10).collect()
1042}
1043
1044/// Get the terminal width from the COLUMNS env var, defaulting to 120.
1045fn terminal_width() -> usize {
1046    std::env::var("COLUMNS")
1047        .ok()
1048        .and_then(|v| v.parse().ok())
1049        .unwrap_or(120)
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[test]
1057    fn truncate_short_string() {
1058        assert_eq!(truncate("hello", 10), "hello");
1059    }
1060
1061    #[test]
1062    fn truncate_exact_length() {
1063        assert_eq!(truncate("hello", 5), "hello");
1064    }
1065
1066    #[test]
1067    fn truncate_long_string() {
1068        assert_eq!(truncate("hello world", 5), "hello…");
1069    }
1070
1071    #[test]
1072    fn truncate_multibyte_safe() {
1073        let result = truncate("日本語テスト", 3);
1074        assert_eq!(result, "日本語…");
1075    }
1076
1077    #[test]
1078    fn build_list_jql_empty() {
1079        assert_eq!(
1080            build_list_jql(None, None, None, None, None, None),
1081            "ORDER BY updated DESC"
1082        );
1083    }
1084
1085    #[test]
1086    fn build_list_jql_escapes_quotes() {
1087        let jql = build_list_jql(None, Some(r#"Done" OR 1=1"#), None, None, None, None);
1088        // The double quote must be backslash-escaped so it cannot break out of the JQL string.
1089        // The resulting clause should be:  status = "Done\" OR 1=1"
1090        assert!(jql.contains(r#"\""#), "double quote must be escaped");
1091        assert!(
1092            jql.contains(r#"status = "Done\""#),
1093            "escaped quote must remain inside the status value string"
1094        );
1095    }
1096
1097    #[test]
1098    fn build_list_jql_project_and_status() {
1099        let jql = build_list_jql(Some("PROJ"), Some("In Progress"), None, None, None, None);
1100        assert!(jql.contains(r#"project = "PROJ""#));
1101        assert!(jql.contains(r#"status = "In Progress""#));
1102    }
1103
1104    #[test]
1105    fn build_list_jql_assignee_me() {
1106        let jql = build_list_jql(None, None, Some("me"), None, None, None);
1107        assert!(jql.contains("currentUser()"));
1108    }
1109
1110    #[test]
1111    fn build_list_jql_issue_type() {
1112        let jql = build_list_jql(None, None, None, Some("Bug"), None, None);
1113        assert!(jql.contains(r#"issuetype = "Bug""#));
1114    }
1115
1116    #[test]
1117    fn build_list_jql_sprint_active() {
1118        let jql = build_list_jql(None, None, None, None, Some("active"), None);
1119        assert!(jql.contains("sprint in openSprints()"));
1120    }
1121
1122    #[test]
1123    fn build_list_jql_sprint_named() {
1124        let jql = build_list_jql(None, None, None, None, Some("Sprint 42"), None);
1125        assert!(jql.contains(r#"sprint = "Sprint 42""#));
1126    }
1127
1128    #[test]
1129    fn colorize_status_done_is_green() {
1130        let result = colorize_status("Done", "Done");
1131        assert!(result.contains("Done"));
1132        // Green ANSI escape code starts with \x1b[32m
1133        assert!(result.contains("\x1b["));
1134    }
1135
1136    #[test]
1137    fn colorize_status_unknown_unchanged() {
1138        let result = colorize_status("Backlog", "Backlog");
1139        assert_eq!(result, "Backlog");
1140    }
1141
1142    /// Ensures an environment variable is removed even if the test panics.
1143    struct EnvVarGuard(&'static str);
1144
1145    impl Drop for EnvVarGuard {
1146        fn drop(&mut self) {
1147            unsafe { std::env::remove_var(self.0) }
1148        }
1149    }
1150
1151    #[test]
1152    fn terminal_width_fallback_parses_columns() {
1153        unsafe { std::env::set_var("COLUMNS", "200") };
1154        let _guard = EnvVarGuard("COLUMNS");
1155        assert_eq!(terminal_width(), 200);
1156    }
1157}