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