Skip to main content

jira_cli/commands/
issues.rs

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