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
234pub(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 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
366pub(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
434fn 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
476fn 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
490fn 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
507fn 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
517fn format_date(s: &str) -> String {
519 s.chars().take(10).collect()
520}
521
522fn 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 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 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 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}