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