Skip to main content

jira_cli/commands/
issues.rs

1use owo_colors::OwoColorize;
2
3use crate::api::{ApiError, Issue, 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    sprint: Option<&str>,
14    jql_extra: Option<&str>,
15    limit: usize,
16    offset: usize,
17) -> Result<(), ApiError> {
18    let jql = build_list_jql(project, status, assignee, sprint, jql_extra);
19    let resp = client.search(&jql, limit, offset).await?;
20
21    if out.json {
22        out.print_data(
23            &serde_json::to_string_pretty(&serde_json::json!({
24                "total": resp.total,
25                "startAt": resp.start_at,
26                "maxResults": resp.max_results,
27                "issues": resp.issues.iter().map(|i| issue_to_json(i, client)).collect::<Vec<_>>(),
28            }))
29            .expect("failed to serialize JSON"),
30        );
31    } else {
32        render_issue_table(&resp.issues, out);
33        if resp.total > resp.start_at + resp.issues.len() {
34            out.print_message(&format!(
35                "Showing {}-{} of {} issues — use --limit or --offset to paginate",
36                resp.start_at + 1,
37                resp.start_at + resp.issues.len(),
38                resp.total
39            ));
40        } else {
41            out.print_message(&format!("{} issues", resp.issues.len()));
42        }
43    }
44    Ok(())
45}
46
47pub async fn show(
48    client: &JiraClient,
49    out: &OutputConfig,
50    key: &str,
51    open: bool,
52) -> Result<(), ApiError> {
53    let issue = client.get_issue(key).await?;
54
55    if open {
56        open_in_browser(&client.browse_url(&issue.key));
57    }
58
59    if out.json {
60        out.print_data(
61            &serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
62                .expect("failed to serialize JSON"),
63        );
64    } else {
65        render_issue_detail(&issue);
66    }
67    Ok(())
68}
69
70#[allow(clippy::too_many_arguments)]
71pub async fn create(
72    client: &JiraClient,
73    out: &OutputConfig,
74    project: &str,
75    issue_type: &str,
76    summary: &str,
77    description: Option<&str>,
78    priority: Option<&str>,
79    labels: Option<&[&str]>,
80    assignee: Option<&str>,
81) -> Result<(), ApiError> {
82    let resp = client
83        .create_issue(
84            project,
85            issue_type,
86            summary,
87            description,
88            priority,
89            labels,
90            assignee,
91        )
92        .await?;
93    let url = client.browse_url(&resp.key);
94    out.print_result(
95        &serde_json::json!({ "key": resp.key, "id": resp.id, "url": url }),
96        &resp.key,
97    );
98    Ok(())
99}
100
101pub async fn update(
102    client: &JiraClient,
103    out: &OutputConfig,
104    key: &str,
105    summary: Option<&str>,
106    description: Option<&str>,
107    priority: Option<&str>,
108) -> Result<(), ApiError> {
109    client
110        .update_issue(key, summary, description, priority)
111        .await?;
112    out.print_result(
113        &serde_json::json!({ "key": key, "updated": true }),
114        &format!("Updated {key}"),
115    );
116    Ok(())
117}
118
119pub async fn comment(
120    client: &JiraClient,
121    out: &OutputConfig,
122    key: &str,
123    body: &str,
124) -> Result<(), ApiError> {
125    let c = client.add_comment(key, body).await?;
126    let url = client.browse_url(key);
127    out.print_result(
128        &serde_json::json!({
129            "id": c.id,
130            "issue": key,
131            "url": url,
132            "author": c.author.display_name,
133            "created": c.created,
134        }),
135        &format!("Comment added to {key}"),
136    );
137    Ok(())
138}
139
140pub async fn transition(
141    client: &JiraClient,
142    out: &OutputConfig,
143    key: &str,
144    to: &str,
145) -> Result<(), ApiError> {
146    let transitions = client.get_transitions(key).await?;
147
148    let matched = transitions
149        .iter()
150        .find(|t| t.name.to_lowercase() == to.to_lowercase() || t.id == to);
151
152    match matched {
153        Some(t) => {
154            let name = t.name.clone();
155            let id = t.id.clone();
156            client.do_transition(key, &id).await?;
157            out.print_result(
158                &serde_json::json!({ "issue": key, "transition": name, "id": id }),
159                &format!("Transitioned {key} → {name}"),
160            );
161        }
162        None => {
163            let hint = transitions
164                .iter()
165                .map(|t| format!("  {} ({})", t.name, t.id))
166                .collect::<Vec<_>>()
167                .join("\n");
168            out.print_message(&format!(
169                "Transition '{to}' not found for {key}. Available:\n{hint}"
170            ));
171            out.print_message(&format!(
172                "Tip: `jira issues list-transitions {key}` shows transitions as JSON."
173            ));
174            return Err(ApiError::NotFound(format!(
175                "Transition '{to}' not found for {key}"
176            )));
177        }
178    }
179    Ok(())
180}
181
182pub async fn list_transitions(
183    client: &JiraClient,
184    out: &OutputConfig,
185    key: &str,
186) -> Result<(), ApiError> {
187    let ts = client.get_transitions(key).await?;
188
189    if out.json {
190        out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
191    } else {
192        let color = use_color();
193        let header = format!("{:<6} {}", "ID", "Name");
194        if color {
195            println!("{}", header.bold());
196        } else {
197            println!("{header}");
198        }
199        for t in &ts {
200            println!("{:<6} {}", t.id, t.name);
201        }
202    }
203    Ok(())
204}
205
206pub async fn assign(
207    client: &JiraClient,
208    out: &OutputConfig,
209    key: &str,
210    assignee: &str,
211) -> Result<(), ApiError> {
212    let account_id = if assignee == "me" {
213        let me = client.get_myself().await?;
214        me.account_id
215    } else if assignee == "none" || assignee == "unassign" {
216        client.assign_issue(key, None).await?;
217        out.print_result(
218            &serde_json::json!({ "issue": key, "assignee": null }),
219            &format!("Unassigned {key}"),
220        );
221        return Ok(());
222    } else {
223        assignee.to_string()
224    };
225
226    client.assign_issue(key, Some(&account_id)).await?;
227    out.print_result(
228        &serde_json::json!({ "issue": key, "accountId": account_id }),
229        &format!("Assigned {key} to {assignee}"),
230    );
231    Ok(())
232}
233
234// ── Rendering ─────────────────────────────────────────────────────────────────
235
236pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
237    if issues.is_empty() {
238        out.print_message("No issues found.");
239        return;
240    }
241
242    let color = use_color();
243    let term_width = terminal_width();
244
245    let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
246    let status_w = issues
247        .iter()
248        .map(|i| i.status().len())
249        .max()
250        .unwrap_or(6)
251        .clamp(6, 14)
252        + 2;
253    let assignee_w = issues
254        .iter()
255        .map(|i| i.assignee().len())
256        .max()
257        .unwrap_or(8)
258        .clamp(8, 18)
259        + 2;
260    let type_w = issues
261        .iter()
262        .map(|i| i.issue_type().len())
263        .max()
264        .unwrap_or(4)
265        .clamp(4, 12)
266        + 2;
267
268    // Give remaining width to summary, minimum 20
269    let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
270    let summary_w = term_width.saturating_sub(fixed).max(20);
271
272    let header = format!(
273        "{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
274        "Key", "Status", "Assignee", "Type", "Summary"
275    );
276    if color {
277        println!("{}", header.bold());
278    } else {
279        println!("{header}");
280    }
281
282    for issue in issues {
283        let key = if color {
284            format!("{:<key_w$}", issue.key).yellow().to_string()
285        } else {
286            format!("{:<key_w$}", issue.key)
287        };
288        let status_val = truncate(issue.status(), status_w - 2);
289        let status = if color {
290            colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
291        } else {
292            format!("{:<status_w$}", status_val)
293        };
294        println!(
295            "{key} {status} {:<assignee_w$} {:<type_w$} {}",
296            truncate(issue.assignee(), assignee_w - 2),
297            truncate(issue.issue_type(), type_w - 2),
298            truncate(issue.summary(), summary_w),
299        );
300    }
301}
302
303fn render_issue_detail(issue: &Issue) {
304    let color = use_color();
305    let key = if color {
306        issue.key.yellow().bold().to_string()
307    } else {
308        issue.key.clone()
309    };
310    println!("{key}  {}", issue.summary());
311    println!();
312    println!("  Type:     {}", issue.issue_type());
313    let status_str = if color {
314        colorize_status(issue.status(), issue.status())
315    } else {
316        issue.status().to_string()
317    };
318    println!("  Status:   {status_str}");
319    println!("  Priority: {}", issue.priority());
320    println!("  Assignee: {}", issue.assignee());
321    if let Some(ref reporter) = issue.fields.reporter {
322        println!("  Reporter: {}", reporter.display_name);
323    }
324    if let Some(ref labels) = issue.fields.labels
325        && !labels.is_empty()
326    {
327        println!("  Labels:   {}", labels.join(", "));
328    }
329    if let Some(ref created) = issue.fields.created {
330        println!("  Created:  {}", format_date(created));
331    }
332    if let Some(ref updated) = issue.fields.updated {
333        println!("  Updated:  {}", format_date(updated));
334    }
335
336    let desc = issue.description_text();
337    if !desc.is_empty() {
338        println!();
339        println!("Description:");
340        for line in desc.lines() {
341            println!("  {line}");
342        }
343    }
344
345    if let Some(ref comment_list) = issue.fields.comment
346        && !comment_list.comments.is_empty()
347    {
348        println!();
349        println!("Comments ({}):", comment_list.total);
350        for c in &comment_list.comments {
351            println!();
352            let author = if color {
353                c.author.display_name.bold().to_string()
354            } else {
355                c.author.display_name.clone()
356            };
357            println!("  {} — {}", author, format_date(&c.created));
358            let body = c.body_text();
359            for line in body.lines() {
360                println!("    {line}");
361            }
362        }
363    }
364}
365
366// ── JSON serialization ────────────────────────────────────────────────────────
367
368pub(crate) fn issue_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
369    serde_json::json!({
370        "key": issue.key,
371        "id": issue.id,
372        "url": client.browse_url(&issue.key),
373        "summary": issue.summary(),
374        "status": issue.status(),
375        "assignee": {
376            "displayName": issue.assignee(),
377            "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
378        },
379        "priority": issue.priority(),
380        "type": issue.issue_type(),
381        "created": issue.fields.created,
382        "updated": issue.fields.updated,
383    })
384}
385
386fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
387    let comments: Vec<serde_json::Value> = issue
388        .fields
389        .comment
390        .as_ref()
391        .map(|cl| {
392            cl.comments
393                .iter()
394                .map(|c| {
395                    serde_json::json!({
396                        "id": c.id,
397                        "author": {
398                            "displayName": c.author.display_name,
399                            "accountId": c.author.account_id,
400                        },
401                        "body": c.body_text(),
402                        "created": c.created,
403                        "updated": c.updated,
404                    })
405                })
406                .collect()
407        })
408        .unwrap_or_default();
409
410    serde_json::json!({
411        "key": issue.key,
412        "id": issue.id,
413        "url": client.browse_url(&issue.key),
414        "summary": issue.summary(),
415        "status": issue.status(),
416        "type": issue.issue_type(),
417        "priority": issue.priority(),
418        "assignee": {
419            "displayName": issue.assignee(),
420            "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
421        },
422        "reporter": issue.fields.reporter.as_ref().map(|r| serde_json::json!({
423            "displayName": r.display_name,
424            "accountId": r.account_id,
425        })),
426        "labels": issue.fields.labels,
427        "description": issue.description_text(),
428        "created": issue.fields.created,
429        "updated": issue.fields.updated,
430        "comments": comments,
431    })
432}
433
434// ── Helpers ───────────────────────────────────────────────────────────────────
435
436fn build_list_jql(
437    project: Option<&str>,
438    status: Option<&str>,
439    assignee: Option<&str>,
440    sprint: Option<&str>,
441    extra: Option<&str>,
442) -> String {
443    let mut parts: Vec<String> = Vec::new();
444
445    if let Some(p) = project {
446        parts.push(format!(r#"project = "{}""#, escape_jql(p)));
447    }
448    if let Some(s) = status {
449        parts.push(format!(r#"status = "{}""#, escape_jql(s)));
450    }
451    if let Some(a) = assignee {
452        if a == "me" {
453            parts.push("assignee = currentUser()".into());
454        } else {
455            parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
456        }
457    }
458    if let Some(s) = sprint {
459        if s == "active" || s == "open" {
460            parts.push("sprint in openSprints()".into());
461        } else {
462            parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
463        }
464    }
465    if let Some(e) = extra {
466        parts.push(format!("({e})"));
467    }
468
469    if parts.is_empty() {
470        "ORDER BY updated DESC".into()
471    } else {
472        format!("{} ORDER BY updated DESC", parts.join(" AND "))
473    }
474}
475
476/// Color-code a Jira status string for terminal output.
477fn colorize_status(status: &str, display: &str) -> String {
478    let lower = status.to_lowercase();
479    if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
480        display.green().to_string()
481    } else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
482        display.yellow().to_string()
483    } else if lower.contains("blocked") || lower.contains("impediment") {
484        display.red().to_string()
485    } else {
486        display.to_string()
487    }
488}
489
490/// Open a URL in the system default browser, printing a warning if it fails.
491fn open_in_browser(url: &str) {
492    #[cfg(target_os = "macos")]
493    let result = std::process::Command::new("open").arg(url).status();
494    #[cfg(target_os = "linux")]
495    let result = std::process::Command::new("xdg-open").arg(url).status();
496    #[cfg(target_os = "windows")]
497    let result = std::process::Command::new("cmd")
498        .args(["/c", "start", url])
499        .status();
500
501    #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
502    if let Err(e) = result {
503        eprintln!("Warning: could not open browser: {e}");
504    }
505}
506
507/// Truncate a string to `max` characters (not bytes), appending `…` if cut.
508fn truncate(s: &str, max: usize) -> String {
509    let mut chars = s.chars();
510    let mut result: String = chars.by_ref().take(max).collect();
511    if chars.next().is_some() {
512        result.push('…');
513    }
514    result
515}
516
517/// Shorten an ISO-8601 timestamp to just the date portion.
518fn format_date(s: &str) -> String {
519    s.chars().take(10).collect()
520}
521
522/// Get the terminal width from the COLUMNS env var, defaulting to 120.
523fn terminal_width() -> usize {
524    std::env::var("COLUMNS")
525        .ok()
526        .and_then(|v| v.parse().ok())
527        .unwrap_or(120)
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn truncate_short_string() {
536        assert_eq!(truncate("hello", 10), "hello");
537    }
538
539    #[test]
540    fn truncate_exact_length() {
541        assert_eq!(truncate("hello", 5), "hello");
542    }
543
544    #[test]
545    fn truncate_long_string() {
546        assert_eq!(truncate("hello world", 5), "hello…");
547    }
548
549    #[test]
550    fn truncate_multibyte_safe() {
551        let result = truncate("日本語テスト", 3);
552        assert_eq!(result, "日本語…");
553    }
554
555    #[test]
556    fn build_list_jql_empty() {
557        assert_eq!(
558            build_list_jql(None, None, None, None, None),
559            "ORDER BY updated DESC"
560        );
561    }
562
563    #[test]
564    fn build_list_jql_escapes_quotes() {
565        let jql = build_list_jql(None, Some(r#"Done" OR 1=1"#), None, None, None);
566        // The double quote must be backslash-escaped so it cannot break out of the JQL string.
567        // The resulting clause should be:  status = "Done\" OR 1=1"
568        assert!(jql.contains(r#"\""#), "double quote must be escaped");
569        assert!(
570            jql.contains(r#"status = "Done\""#),
571            "escaped quote must remain inside the status value string"
572        );
573    }
574
575    #[test]
576    fn build_list_jql_project_and_status() {
577        let jql = build_list_jql(Some("PROJ"), Some("In Progress"), None, None, None);
578        assert!(jql.contains(r#"project = "PROJ""#));
579        assert!(jql.contains(r#"status = "In Progress""#));
580    }
581
582    #[test]
583    fn build_list_jql_assignee_me() {
584        let jql = build_list_jql(None, None, Some("me"), None, None);
585        assert!(jql.contains("currentUser()"));
586    }
587
588    #[test]
589    fn build_list_jql_sprint_active() {
590        let jql = build_list_jql(None, None, None, Some("active"), None);
591        assert!(jql.contains("sprint in openSprints()"));
592    }
593
594    #[test]
595    fn build_list_jql_sprint_named() {
596        let jql = build_list_jql(None, None, None, Some("Sprint 42"), None);
597        assert!(jql.contains(r#"sprint = "Sprint 42""#));
598    }
599
600    #[test]
601    fn colorize_status_done_is_green() {
602        let result = colorize_status("Done", "Done");
603        assert!(result.contains("Done"));
604        // Green ANSI escape code starts with \x1b[32m
605        assert!(result.contains("\x1b["));
606    }
607
608    #[test]
609    fn colorize_status_unknown_unchanged() {
610        let result = colorize_status("Backlog", "Backlog");
611        assert_eq!(result, "Backlog");
612    }
613
614    /// Ensures an environment variable is removed even if the test panics.
615    struct EnvVarGuard(&'static str);
616
617    impl Drop for EnvVarGuard {
618        fn drop(&mut self) {
619            unsafe { std::env::remove_var(self.0) }
620        }
621    }
622
623    #[test]
624    fn terminal_width_fallback_parses_columns() {
625        unsafe { std::env::set_var("COLUMNS", "200") };
626        let _guard = EnvVarGuard("COLUMNS");
627        assert_eq!(terminal_width(), 200);
628    }
629}