1use owo_colors::OwoColorize;
2
3use crate::api::{
4 ApiError, Issue, IssueDraft, IssueLink, IssueUpdate, JiraClient, Version, escape_jql,
5};
6use crate::output::{OutputConfig, use_color};
7
8#[derive(Default)]
13pub struct ListFilters<'a> {
14 pub project: Option<&'a str>,
15 pub status: Option<&'a str>,
16 pub assignee: Option<&'a str>,
17 pub issue_type: Option<&'a str>,
18 pub sprint: Option<&'a str>,
19 pub components: Option<&'a [&'a str]>,
20 pub labels: Option<&'a [&'a str]>,
21 pub fix_versions: Option<&'a [&'a str]>,
22 pub jql_extra: Option<&'a str>,
23}
24
25pub async fn list(
26 client: &JiraClient,
27 out: &OutputConfig,
28 filters: ListFilters<'_>,
29 limit: usize,
30 offset: usize,
31 all: bool,
32) -> Result<(), ApiError> {
33 let jql = build_list_jql(&filters);
34 if all {
35 let issues = fetch_all_issues(client, &jql).await?;
36 let n = issues.len();
37 render_results(out, &issues, Some(n), 0, n, client, false);
38 } else {
39 let resp = client.search(&jql, limit, offset).await?;
40 let more = !resp.is_last;
41 render_results(
42 out,
43 &resp.issues,
44 resp.total,
45 resp.start_at,
46 resp.max_results,
47 client,
48 more,
49 );
50 }
51 Ok(())
52}
53
54pub async fn mine(
56 client: &JiraClient,
57 out: &OutputConfig,
58 mut filters: ListFilters<'_>,
59 limit: usize,
60 all: bool,
61) -> Result<(), ApiError> {
62 filters.assignee = Some("me");
63 list(client, out, filters, limit, 0, all).await
64}
65
66pub async fn comments(client: &JiraClient, out: &OutputConfig, key: &str) -> Result<(), ApiError> {
68 let issue = client.get_issue(key).await?;
69 let comment_list = issue.fields.comment.as_ref();
70
71 if out.json {
72 let comments_json: Vec<serde_json::Value> = comment_list
73 .map(|cl| {
74 cl.comments
75 .iter()
76 .map(|c| {
77 serde_json::json!({
78 "id": c.id,
79 "author": {
80 "displayName": c.author.display_name,
81 "accountId": c.author.account_id,
82 },
83 "body": c.body_text(),
84 "created": c.created,
85 "updated": c.updated,
86 })
87 })
88 .collect()
89 })
90 .unwrap_or_default();
91 let total = comment_list.map(|cl| cl.total).unwrap_or(0);
92 out.print_data(
93 &serde_json::to_string_pretty(&serde_json::json!({
94 "issue": key,
95 "total": total,
96 "comments": comments_json,
97 }))
98 .expect("failed to serialize JSON"),
99 );
100 } else {
101 match comment_list {
102 None => {
103 out.print_message(&format!("No comments on {key}."));
104 }
105 Some(cl) if cl.comments.is_empty() => {
106 out.print_message(&format!("No comments on {key}."));
107 }
108 Some(cl) => {
109 let color = use_color();
110 out.print_message(&format!("Comments on {key} ({}):", cl.total));
111 for c in &cl.comments {
112 println!();
113 let author = if color {
114 c.author.display_name.bold().to_string()
115 } else {
116 c.author.display_name.clone()
117 };
118 println!(" {} — {}", author, format_date(&c.created));
119 for line in c.body_text().lines() {
120 println!(" {line}");
121 }
122 }
123 }
124 }
125 }
126 Ok(())
127}
128
129pub async fn fetch_all_issues(client: &JiraClient, jql: &str) -> Result<Vec<Issue>, ApiError> {
131 const PAGE_SIZE: usize = 100;
132 let mut all: Vec<Issue> = Vec::new();
133 let mut offset = 0;
134 loop {
135 let resp = client.search(jql, PAGE_SIZE, offset).await?;
136 let fetched = resp.issues.len();
137 all.extend(resp.issues);
138 offset += fetched;
139 if resp.is_last || fetched == 0 {
140 break;
141 }
142 }
143 Ok(all)
144}
145
146fn render_results(
147 out: &OutputConfig,
148 issues: &[Issue],
149 total: Option<usize>,
150 start_at: usize,
151 max_results: usize,
152 client: &JiraClient,
153 more: bool,
154) {
155 if out.json {
156 let total_json: serde_json::Value = match total {
157 Some(n) => serde_json::json!(n),
158 None => serde_json::Value::Null,
159 };
160 out.print_data(
161 &serde_json::to_string_pretty(&serde_json::json!({
162 "total": total_json,
163 "startAt": start_at,
164 "maxResults": max_results,
165 "issues": issues.iter().map(|i| issue_to_json(i, client)).collect::<Vec<_>>(),
166 }))
167 .expect("failed to serialize JSON"),
168 );
169 } else {
170 render_issue_table(issues, out);
171 if more {
172 match total {
173 Some(n) => out.print_message(&format!(
174 "Showing {}-{} of {} issues — use --limit/--offset or --all to paginate",
175 start_at + 1,
176 start_at + issues.len(),
177 n
178 )),
179 None => out.print_message(&format!(
180 "Showing {}-{} issues (more available) — use --limit/--offset or --all to paginate",
181 start_at + 1,
182 start_at + issues.len()
183 )),
184 }
185 } else {
186 out.print_message(&format!("{} issues", issues.len()));
187 }
188 }
189}
190
191pub async fn show(
192 client: &JiraClient,
193 out: &OutputConfig,
194 key: &str,
195 open: bool,
196) -> Result<(), ApiError> {
197 let issue = client.get_issue(key).await?;
198
199 if open {
200 open_in_browser(&client.browse_url(&issue.key));
201 }
202
203 if out.json {
204 out.print_data(
205 &serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
206 .expect("failed to serialize JSON"),
207 );
208 } else {
209 render_issue_detail(&issue);
210 }
211 Ok(())
212}
213
214pub async fn create(
215 client: &JiraClient,
216 out: &OutputConfig,
217 draft: &IssueDraft<'_>,
218 sprint: Option<&str>,
219 custom_fields: &[(String, serde_json::Value)],
220) -> Result<(), ApiError> {
221 let resp = client.create_issue(draft, custom_fields).await?;
222 let url = client.browse_url(&resp.key);
223
224 let mut result = serde_json::json!({ "key": resp.key, "id": resp.id, "url": url });
225 if let Some(p) = draft.parent {
226 result["parent"] = serde_json::json!(p);
227 }
228 if let Some(s) = sprint {
229 let resolved = client.resolve_sprint(s).await?;
230 client.move_issue_to_sprint(&resp.key, resolved.id).await?;
231 result["sprintId"] = serde_json::json!(resolved.id);
232 result["sprintName"] = serde_json::json!(resolved.name);
233 }
234 out.print_result(&result, &resp.key);
235 Ok(())
236}
237
238pub async fn update(
239 client: &JiraClient,
240 out: &OutputConfig,
241 key: &str,
242 update: &IssueUpdate<'_>,
243 custom_fields: &[(String, serde_json::Value)],
244) -> Result<(), ApiError> {
245 client.update_issue(key, update, custom_fields).await?;
246 out.print_result(
247 &serde_json::json!({ "key": key, "updated": true }),
248 &format!("Updated {key}"),
249 );
250 Ok(())
251}
252
253pub async fn move_to_sprint(
255 client: &JiraClient,
256 out: &OutputConfig,
257 key: &str,
258 sprint: &str,
259) -> Result<(), ApiError> {
260 let resolved = client.resolve_sprint(sprint).await?;
261 client.move_issue_to_sprint(key, resolved.id).await?;
262 out.print_result(
263 &serde_json::json!({
264 "issue": key,
265 "sprintId": resolved.id,
266 "sprintName": resolved.name,
267 }),
268 &format!("Moved {key} to {} ({})", resolved.name, resolved.id),
269 );
270 Ok(())
271}
272
273pub async fn comment(
274 client: &JiraClient,
275 out: &OutputConfig,
276 key: &str,
277 body: &str,
278) -> Result<(), ApiError> {
279 let c = client.add_comment(key, body).await?;
280 let url = client.browse_url(key);
281 out.print_result(
282 &serde_json::json!({
283 "id": c.id,
284 "issue": key,
285 "url": url,
286 "author": c.author.display_name,
287 "created": c.created,
288 }),
289 &format!("Comment added to {key}"),
290 );
291 Ok(())
292}
293
294pub async fn transition(
295 client: &JiraClient,
296 out: &OutputConfig,
297 key: &str,
298 to: &str,
299) -> Result<(), ApiError> {
300 let transitions = client.get_transitions(key).await?;
301
302 let matched = transitions
303 .iter()
304 .find(|t| t.name.to_lowercase() == to.to_lowercase() || t.id == to);
305
306 match matched {
307 Some(t) => {
308 let name = t.name.clone();
309 let id = t.id.clone();
310 let status =
311 t.to.as_ref()
312 .map(|tt| tt.name.clone())
313 .unwrap_or_else(|| name.clone());
314 client.do_transition(key, &id).await?;
315 out.print_result(
316 &serde_json::json!({ "issue": key, "transition": name, "status": status, "id": id }),
317 &format!("Transitioned {key} → {status}"),
318 );
319 }
320 None => {
321 let hint = transitions
322 .iter()
323 .map(|t| format!(" {} ({})", t.name, t.id))
324 .collect::<Vec<_>>()
325 .join("\n");
326 out.print_message(&format!(
327 "Transition '{to}' not found for {key}. Available:\n{hint}"
328 ));
329 out.print_message(&format!(
330 "Tip: `jira issues list-transitions {key}` shows transitions as JSON."
331 ));
332 return Err(ApiError::NotFound(format!(
333 "Transition '{to}' not found for {key}"
334 )));
335 }
336 }
337 Ok(())
338}
339
340pub async fn list_transitions(
341 client: &JiraClient,
342 out: &OutputConfig,
343 key: &str,
344) -> Result<(), ApiError> {
345 let ts = client.get_transitions(key).await?;
346
347 if out.json {
348 out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
349 } else {
350 let color = use_color();
351 let header = format!("{:<6} {}", "ID", "Name");
352 if color {
353 println!("{}", header.bold());
354 } else {
355 println!("{header}");
356 }
357 for t in &ts {
358 println!("{:<6} {}", t.id, t.name);
359 }
360 }
361 Ok(())
362}
363
364pub async fn assign(
365 client: &JiraClient,
366 out: &OutputConfig,
367 key: &str,
368 assignee: &str,
369) -> Result<(), ApiError> {
370 let account_id = if assignee == "me" {
371 let me = client.get_myself().await?;
372 me.account_id
373 } else if assignee == "none" || assignee == "unassign" {
374 client.assign_issue(key, None).await?;
375 out.print_result(
376 &serde_json::json!({ "issue": key, "assignee": null }),
377 &format!("Unassigned {key}"),
378 );
379 return Ok(());
380 } else {
381 assignee.to_string()
382 };
383
384 client.assign_issue(key, Some(&account_id)).await?;
385 out.print_result(
386 &serde_json::json!({ "issue": key, "accountId": account_id }),
387 &format!("Assigned {key} to {assignee}"),
388 );
389 Ok(())
390}
391
392pub async fn link_types(client: &JiraClient, out: &OutputConfig) -> Result<(), ApiError> {
394 let types = client.get_link_types().await?;
395
396 if out.json {
397 out.print_data(
398 &serde_json::to_string_pretty(&serde_json::json!(
399 types
400 .iter()
401 .map(|t| serde_json::json!({
402 "id": t.id,
403 "name": t.name,
404 "inward": t.inward,
405 "outward": t.outward,
406 }))
407 .collect::<Vec<_>>()
408 ))
409 .expect("failed to serialize JSON"),
410 );
411 return Ok(());
412 }
413
414 for t in &types {
415 println!(
416 "{:<20} outward: {} / inward: {}",
417 t.name, t.outward, t.inward
418 );
419 }
420 Ok(())
421}
422
423pub async fn link(
425 client: &JiraClient,
426 out: &OutputConfig,
427 from_key: &str,
428 to_key: &str,
429 link_type: &str,
430) -> Result<(), ApiError> {
431 client.link_issues(from_key, to_key, link_type).await?;
432 out.print_result(
433 &serde_json::json!({
434 "from": from_key,
435 "to": to_key,
436 "type": link_type,
437 }),
438 &format!("Linked {from_key} → {to_key} ({link_type})"),
439 );
440 Ok(())
441}
442
443pub async fn unlink(
445 client: &JiraClient,
446 out: &OutputConfig,
447 link_id: &str,
448) -> Result<(), ApiError> {
449 client.unlink_issues(link_id).await?;
450 out.print_result(
451 &serde_json::json!({ "linkId": link_id }),
452 &format!("Removed link {link_id}"),
453 );
454 Ok(())
455}
456
457pub async fn log_work(
459 client: &JiraClient,
460 out: &OutputConfig,
461 key: &str,
462 time_spent: &str,
463 comment: Option<&str>,
464 started: Option<&str>,
465) -> Result<(), ApiError> {
466 let entry = client.log_work(key, time_spent, comment, started).await?;
467 out.print_result(
468 &serde_json::json!({
469 "id": entry.id,
470 "issue": key,
471 "timeSpent": entry.time_spent,
472 "timeSpentSeconds": entry.time_spent_seconds,
473 "author": entry.author.display_name,
474 "started": entry.started,
475 "created": entry.created,
476 }),
477 &format!("Logged {} on {key}", entry.time_spent),
478 );
479 Ok(())
480}
481
482pub async fn bulk_transition(
484 client: &JiraClient,
485 out: &OutputConfig,
486 jql: &str,
487 to: &str,
488 dry_run: bool,
489) -> Result<(), ApiError> {
490 let issues = fetch_all_issues(client, jql).await?;
491
492 if issues.is_empty() {
493 out.print_message("No issues matched the query.");
494 return Ok(());
495 }
496
497 let mut results: Vec<serde_json::Value> = Vec::new();
498 let mut succeeded = 0usize;
499 let mut failed = 0usize;
500
501 for issue in &issues {
502 if dry_run {
503 results.push(serde_json::json!({
504 "key": issue.key,
505 "status": issue.status(),
506 "action": "would transition",
507 "to": to,
508 }));
509 continue;
510 }
511
512 let transitions = client.get_transitions(&issue.key).await?;
513 let matched = transitions.iter().find(|t| {
514 t.name.eq_ignore_ascii_case(to)
515 || t.to
516 .as_ref()
517 .is_some_and(|tt| tt.name.eq_ignore_ascii_case(to))
518 || t.id == to
519 });
520
521 match matched {
522 Some(t) => match client.do_transition(&issue.key, &t.id).await {
523 Ok(()) => {
524 succeeded += 1;
525 results.push(serde_json::json!({
526 "key": issue.key,
527 "from": issue.status(),
528 "to": to,
529 "ok": true,
530 }));
531 }
532 Err(e) => {
533 failed += 1;
534 results.push(serde_json::json!({
535 "key": issue.key,
536 "ok": false,
537 "error": e.to_string(),
538 }));
539 }
540 },
541 None => {
542 failed += 1;
543 results.push(serde_json::json!({
544 "key": issue.key,
545 "ok": false,
546 "error": format!("transition '{to}' not available"),
547 }));
548 }
549 }
550 }
551
552 if out.json {
553 out.print_data(
554 &serde_json::to_string_pretty(&serde_json::json!({
555 "dryRun": dry_run,
556 "total": issues.len(),
557 "succeeded": succeeded,
558 "failed": failed,
559 "issues": results,
560 }))
561 .expect("failed to serialize JSON"),
562 );
563 } else if dry_run {
564 render_issue_table(&issues, out);
565 out.print_message(&format!(
566 "Dry run: {} issues would be transitioned to '{to}'",
567 issues.len()
568 ));
569 } else {
570 out.print_message(&format!(
571 "Transitioned {succeeded}/{} issues to '{to}'{}",
572 issues.len(),
573 if failed > 0 {
574 format!(" ({failed} failed)")
575 } else {
576 String::new()
577 }
578 ));
579 }
580 Ok(())
581}
582
583pub async fn bulk_assign(
585 client: &JiraClient,
586 out: &OutputConfig,
587 jql: &str,
588 assignee: &str,
589 dry_run: bool,
590) -> Result<(), ApiError> {
591 let account_id: Option<String> = match assignee {
593 "me" => {
594 let me = client.get_myself().await?;
595 Some(me.account_id)
596 }
597 "none" | "unassign" => None,
598 id => Some(id.to_string()),
599 };
600
601 let issues = fetch_all_issues(client, jql).await?;
602
603 if issues.is_empty() {
604 out.print_message("No issues matched the query.");
605 return Ok(());
606 }
607
608 let mut results: Vec<serde_json::Value> = Vec::new();
609 let mut succeeded = 0usize;
610 let mut failed = 0usize;
611
612 for issue in &issues {
613 if dry_run {
614 results.push(serde_json::json!({
615 "key": issue.key,
616 "currentAssignee": issue.assignee(),
617 "action": "would assign",
618 "to": assignee,
619 }));
620 continue;
621 }
622
623 match client.assign_issue(&issue.key, account_id.as_deref()).await {
624 Ok(()) => {
625 succeeded += 1;
626 results.push(serde_json::json!({
627 "key": issue.key,
628 "assignee": assignee,
629 "ok": true,
630 }));
631 }
632 Err(e) => {
633 failed += 1;
634 results.push(serde_json::json!({
635 "key": issue.key,
636 "ok": false,
637 "error": e.to_string(),
638 }));
639 }
640 }
641 }
642
643 if out.json {
644 out.print_data(
645 &serde_json::to_string_pretty(&serde_json::json!({
646 "dryRun": dry_run,
647 "total": issues.len(),
648 "succeeded": succeeded,
649 "failed": failed,
650 "issues": results,
651 }))
652 .expect("failed to serialize JSON"),
653 );
654 } else if dry_run {
655 render_issue_table(&issues, out);
656 out.print_message(&format!(
657 "Dry run: {} issues would be assigned to '{assignee}'",
658 issues.len()
659 ));
660 } else {
661 out.print_message(&format!(
662 "Assigned {succeeded}/{} issues to '{assignee}'{}",
663 issues.len(),
664 if failed > 0 {
665 format!(" ({failed} failed)")
666 } else {
667 String::new()
668 }
669 ));
670 }
671 Ok(())
672}
673
674pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
677 if issues.is_empty() {
678 out.print_message("No issues found.");
679 return;
680 }
681
682 let color = use_color();
683 let term_width = terminal_width();
684
685 let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
686 let status_w = issues
687 .iter()
688 .map(|i| i.status().len())
689 .max()
690 .unwrap_or(6)
691 .clamp(6, 14)
692 + 2;
693 let assignee_w = issues
694 .iter()
695 .map(|i| i.assignee().len())
696 .max()
697 .unwrap_or(8)
698 .clamp(8, 18)
699 + 2;
700 let type_w = issues
701 .iter()
702 .map(|i| i.issue_type().len())
703 .max()
704 .unwrap_or(4)
705 .clamp(4, 12)
706 + 2;
707
708 let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
710 let summary_w = term_width.saturating_sub(fixed).max(20);
711
712 let header = format!(
713 "{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
714 "Key", "Status", "Assignee", "Type", "Summary"
715 );
716 if color {
717 println!("{}", header.bold());
718 } else {
719 println!("{header}");
720 }
721
722 for issue in issues {
723 let key = if color {
724 format!("{:<key_w$}", issue.key).yellow().to_string()
725 } else {
726 format!("{:<key_w$}", issue.key)
727 };
728 let status_val = truncate(issue.status(), status_w - 2);
729 let status = if color {
730 colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
731 } else {
732 format!("{:<status_w$}", status_val)
733 };
734 println!(
735 "{key} {status} {:<assignee_w$} {:<type_w$} {}",
736 truncate(issue.assignee(), assignee_w - 2),
737 truncate(issue.issue_type(), type_w - 2),
738 truncate(issue.summary(), summary_w),
739 );
740 }
741}
742
743fn render_issue_detail(issue: &Issue) {
744 let mut stdout = std::io::stdout().lock();
745 write_issue_detail(&mut stdout, issue).expect("stdout write");
746}
747
748fn write_issue_detail<W: std::io::Write>(out: &mut W, issue: &Issue) -> std::io::Result<()> {
749 let color = use_color();
750 let key = if color {
751 issue.key.yellow().bold().to_string()
752 } else {
753 issue.key.clone()
754 };
755 writeln!(out, "{key} {}", issue.summary())?;
756 writeln!(out)?;
757 writeln!(out, " Type: {}", issue.issue_type())?;
758 let status_str = if color {
759 colorize_status(issue.status(), issue.status())
760 } else {
761 issue.status().to_string()
762 };
763 writeln!(out, " Status: {status_str}")?;
764 writeln!(out, " Priority: {}", issue.priority())?;
765 writeln!(out, " Assignee: {}", issue.assignee())?;
766 if let Some(ref reporter) = issue.fields.reporter {
767 writeln!(out, " Reporter: {}", reporter.display_name)?;
768 }
769 if let Some(ref labels) = issue.fields.labels
770 && !labels.is_empty()
771 {
772 writeln!(out, " Labels: {}", labels.join(", "))?;
773 }
774 if let Some(ref components) = issue.fields.components
775 && !components.is_empty()
776 {
777 let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
778 writeln!(out, " Components: {}", names.join(", "))?;
779 }
780 if let Some(ref fix_versions) = issue.fields.fix_versions
781 && !fix_versions.is_empty()
782 {
783 let names: Vec<&str> = fix_versions.iter().map(|v| v.name.as_str()).collect();
784 writeln!(out, " Fix Versions: {}", names.join(", "))?;
785 }
786 if let Some(ref versions) = issue.fields.versions
787 && !versions.is_empty()
788 {
789 let names: Vec<&str> = versions.iter().map(|v| v.name.as_str()).collect();
790 writeln!(out, " Affects Versions: {}", names.join(", "))?;
791 }
792 if let Some(ref created) = issue.fields.created {
793 writeln!(out, " Created: {}", format_date(created))?;
794 }
795 if let Some(ref updated) = issue.fields.updated {
796 writeln!(out, " Updated: {}", format_date(updated))?;
797 }
798
799 let desc = issue.description_text();
800 if !desc.is_empty() {
801 writeln!(out)?;
802 writeln!(out, "Description:")?;
803 for line in desc.lines() {
804 writeln!(out, " {line}")?;
805 }
806 }
807
808 if let Some(ref links) = issue.fields.issue_links
809 && !links.is_empty()
810 {
811 writeln!(out)?;
812 writeln!(out, "Links:")?;
813 for link in links {
814 write_issue_link(out, link)?;
815 }
816 }
817
818 if let Some(ref comment_list) = issue.fields.comment
819 && !comment_list.comments.is_empty()
820 {
821 writeln!(out)?;
822 writeln!(out, "Comments ({}):", comment_list.total)?;
823 for c in &comment_list.comments {
824 writeln!(out)?;
825 let author = if color {
826 c.author.display_name.bold().to_string()
827 } else {
828 c.author.display_name.clone()
829 };
830 writeln!(out, " {} — {}", author, format_date(&c.created))?;
831 let body = c.body_text();
832 for line in body.lines() {
833 writeln!(out, " {line}")?;
834 }
835 }
836 }
837 Ok(())
838}
839
840fn write_issue_link<W: std::io::Write>(out: &mut W, link: &IssueLink) -> std::io::Result<()> {
841 if let Some(ref out_issue) = link.outward_issue {
842 writeln!(
843 out,
844 " [{}] {} {} — {}",
845 link.id, link.link_type.outward, out_issue.key, out_issue.fields.summary
846 )?;
847 }
848 if let Some(ref in_issue) = link.inward_issue {
849 writeln!(
850 out,
851 " [{}] {} {} — {}",
852 link.id, link.link_type.inward, in_issue.key, in_issue.fields.summary
853 )?;
854 }
855 Ok(())
856}
857
858pub(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 version_to_json(v: &Version) -> serde_json::Value {
879 serde_json::json!({
880 "id": v.id,
881 "name": v.name,
882 "description": v.description,
883 "released": v.released,
884 "archived": v.archived,
885 "releaseDate": v.release_date,
886 })
887}
888
889pub fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
890 let comments: Vec<serde_json::Value> = issue
891 .fields
892 .comment
893 .as_ref()
894 .map(|cl| {
895 cl.comments
896 .iter()
897 .map(|c| {
898 serde_json::json!({
899 "id": c.id,
900 "author": {
901 "displayName": c.author.display_name,
902 "accountId": c.author.account_id,
903 },
904 "body": c.body_text(),
905 "created": c.created,
906 "updated": c.updated,
907 })
908 })
909 .collect()
910 })
911 .unwrap_or_default();
912
913 let issue_links: Vec<serde_json::Value> = issue
914 .fields
915 .issue_links
916 .as_deref()
917 .unwrap_or_default()
918 .iter()
919 .map(|link| {
920 let sentence = if let Some(ref out_issue) = link.outward_issue {
921 format!("{} {} {}", issue.key, link.link_type.outward, out_issue.key)
922 } else if let Some(ref in_issue) = link.inward_issue {
923 format!("{} {} {}", issue.key, link.link_type.inward, in_issue.key)
924 } else {
925 String::new()
926 };
927 serde_json::json!({
928 "id": link.id,
929 "sentence": sentence,
930 "type": {
931 "id": link.link_type.id,
932 "name": link.link_type.name,
933 "inward": link.link_type.inward,
934 "outward": link.link_type.outward,
935 },
936 "outwardIssue": link.outward_issue.as_ref().map(|i| serde_json::json!({
937 "key": i.key,
938 "summary": i.fields.summary,
939 "status": i.fields.status.name,
940 })),
941 "inwardIssue": link.inward_issue.as_ref().map(|i| serde_json::json!({
942 "key": i.key,
943 "summary": i.fields.summary,
944 "status": i.fields.status.name,
945 })),
946 })
947 })
948 .collect();
949
950 serde_json::json!({
951 "key": issue.key,
952 "id": issue.id,
953 "url": client.browse_url(&issue.key),
954 "summary": issue.summary(),
955 "status": issue.status(),
956 "type": issue.issue_type(),
957 "priority": issue.priority(),
958 "assignee": {
959 "displayName": issue.assignee(),
960 "accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
961 },
962 "reporter": issue.fields.reporter.as_ref().map(|r| serde_json::json!({
963 "displayName": r.display_name,
964 "accountId": r.account_id,
965 })),
966 "labels": issue.fields.labels,
967 "components": issue.fields.components,
968 "fixVersions": issue.fields.fix_versions.as_ref().map(|fvs| {
969 fvs.iter().map(version_to_json).collect::<Vec<_>>()
970 }),
971 "affectedVersions": issue.fields.versions.as_ref().map(|vs| {
972 vs.iter().map(version_to_json).collect::<Vec<_>>()
973 }),
974 "description": issue.description_text(),
975 "created": issue.fields.created,
976 "updated": issue.fields.updated,
977 "comments": comments,
978 "issueLinks": issue_links,
979 })
980}
981
982fn jql_multi_value(field: &str, values: &[&str]) -> Option<String> {
985 match values.len() {
986 0 => None,
987 1 => Some(format!(r#"{field} = "{}""#, escape_jql(values[0]))),
988 _ => {
989 let quoted: Vec<String> = values
990 .iter()
991 .map(|v| format!(r#""{}""#, escape_jql(v)))
992 .collect();
993 Some(format!("{field} in ({})", quoted.join(", ")))
994 }
995 }
996}
997
998fn build_list_jql(filters: &ListFilters<'_>) -> String {
999 let mut parts: Vec<String> = Vec::new();
1000
1001 if let Some(p) = filters.project {
1002 parts.push(format!(r#"project = "{}""#, escape_jql(p)));
1003 }
1004 if let Some(s) = filters.status {
1005 parts.push(format!(r#"status = "{}""#, escape_jql(s)));
1006 }
1007 if let Some(a) = filters.assignee {
1008 if a == "me" {
1009 parts.push("assignee = currentUser()".into());
1010 } else {
1011 parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
1012 }
1013 }
1014 if let Some(t) = filters.issue_type {
1015 parts.push(format!(r#"issuetype = "{}""#, escape_jql(t)));
1016 }
1017 if let Some(s) = filters.sprint {
1018 if s == "active" || s == "open" {
1019 parts.push("sprint in openSprints()".into());
1020 } else {
1021 parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
1022 }
1023 }
1024 if let Some(comps) = filters.components {
1025 parts.extend(jql_multi_value("component", comps));
1026 }
1027 if let Some(lbls) = filters.labels {
1028 parts.extend(jql_multi_value("labels", lbls));
1029 }
1030 if let Some(fvs) = filters.fix_versions {
1031 parts.extend(jql_multi_value("fixVersion", fvs));
1032 }
1033 if let Some(e) = filters.jql_extra {
1034 parts.push(format!("({e})"));
1035 }
1036
1037 if parts.is_empty() {
1038 "ORDER BY updated DESC".into()
1039 } else {
1040 format!("{} ORDER BY updated DESC", parts.join(" AND "))
1041 }
1042}
1043
1044fn colorize_status(status: &str, display: &str) -> String {
1046 let lower = status.to_lowercase();
1047 if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
1048 display.green().to_string()
1049 } else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
1050 display.yellow().to_string()
1051 } else if lower.contains("blocked") || lower.contains("impediment") {
1052 display.red().to_string()
1053 } else {
1054 display.to_string()
1055 }
1056}
1057
1058fn open_in_browser(url: &str) {
1060 #[cfg(target_os = "macos")]
1061 let result = std::process::Command::new("open").arg(url).status();
1062 #[cfg(target_os = "linux")]
1063 let result = std::process::Command::new("xdg-open").arg(url).status();
1064 #[cfg(target_os = "windows")]
1065 let result = std::process::Command::new("cmd")
1066 .args(["/c", "start", url])
1067 .status();
1068
1069 #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
1070 if let Err(e) = result {
1071 eprintln!("Warning: could not open browser: {e}");
1072 }
1073}
1074
1075fn truncate(s: &str, max: usize) -> String {
1077 let mut chars = s.chars();
1078 let mut result: String = chars.by_ref().take(max).collect();
1079 if chars.next().is_some() {
1080 result.push('…');
1081 }
1082 result
1083}
1084
1085fn format_date(s: &str) -> String {
1087 s.chars().take(10).collect()
1088}
1089
1090const MIN_TERMINAL_WIDTH: usize = 60;
1093
1094const DEFAULT_TERMINAL_WIDTH: usize = 120;
1097
1098fn terminal_width() -> usize {
1104 use std::io::IsTerminal;
1105
1106 let tty_width = std::io::stdout()
1107 .is_terminal()
1108 .then(terminal_size::terminal_size)
1109 .flatten()
1110 .map(|(terminal_size::Width(w), _)| w as usize);
1111 let columns = std::env::var("COLUMNS").ok().and_then(|v| v.parse().ok());
1112
1113 resolve_terminal_width(tty_width, columns)
1114}
1115
1116fn resolve_terminal_width(tty_width: Option<usize>, columns: Option<usize>) -> usize {
1120 if let Some(w) = tty_width {
1121 return w.max(MIN_TERMINAL_WIDTH);
1122 }
1123 columns.unwrap_or(DEFAULT_TERMINAL_WIDTH)
1124}
1125
1126pub async fn resolve_assignee_arg(
1133 client: &JiraClient,
1134 arg: Option<&str>,
1135) -> Result<Option<Option<String>>, ApiError> {
1136 match arg {
1137 None => Ok(None),
1138 Some("none") => Ok(Some(None)),
1139 Some("me") => {
1140 let me = client.get_myself().await?;
1141 Ok(Some(Some(me.account_id)))
1142 }
1143 Some(id) => Ok(Some(Some(id.to_string()))),
1144 }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use super::*;
1150 use crate::api::types::{IssueFields, IssueTypeField, StatusField, Version};
1151
1152 fn issue_fixture(fix: Option<Vec<Version>>, aff: Option<Vec<Version>>) -> Issue {
1153 Issue {
1154 id: "10001".into(),
1155 key: "PROJ-1".into(),
1156 url: None,
1157 fields: IssueFields {
1158 summary: "Test".into(),
1159 status: StatusField {
1160 name: "Open".into(),
1161 },
1162 assignee: None,
1163 reporter: None,
1164 priority: None,
1165 issuetype: IssueTypeField { name: "Bug".into() },
1166 description: None,
1167 labels: None,
1168 components: None,
1169 fix_versions: fix,
1170 versions: aff,
1171 created: None,
1172 updated: None,
1173 comment: None,
1174 issue_links: None,
1175 },
1176 }
1177 }
1178
1179 fn make_version(id: &str, name: &str) -> Version {
1180 Version {
1181 id: id.into(),
1182 name: name.into(),
1183 description: None,
1184 released: None,
1185 archived: None,
1186 release_date: None,
1187 }
1188 }
1189
1190 #[test]
1191 fn write_issue_detail_renders_fix_versions_line() {
1192 let issue = issue_fixture(
1193 Some(vec![make_version("1", "1.2.0"), make_version("2", "1.3.0")]),
1194 None,
1195 );
1196 let mut buf = Vec::new();
1197 write_issue_detail(&mut buf, &issue).unwrap();
1198 let out = String::from_utf8(buf).unwrap();
1199 assert!(
1200 out.contains(" Fix Versions: 1.2.0, 1.3.0"),
1201 "expected rendered fix-versions line, got:\n{out}"
1202 );
1203 }
1204
1205 #[test]
1206 fn write_issue_detail_renders_affects_versions_line() {
1207 let issue = issue_fixture(None, Some(vec![make_version("5", "1.1.0")]));
1208 let mut buf = Vec::new();
1209 write_issue_detail(&mut buf, &issue).unwrap();
1210 let out = String::from_utf8(buf).unwrap();
1211 assert!(
1212 out.contains(" Affects Versions: 1.1.0"),
1213 "expected affects-versions line, got:\n{out}"
1214 );
1215 }
1216
1217 #[test]
1218 fn write_issue_detail_omits_version_lines_when_empty() {
1219 let issue = issue_fixture(Some(vec![]), None);
1220 let mut buf = Vec::new();
1221 write_issue_detail(&mut buf, &issue).unwrap();
1222 let out = String::from_utf8(buf).unwrap();
1223 assert!(
1224 !out.contains("Fix Versions:"),
1225 "should omit fix versions header for empty slice, got:\n{out}"
1226 );
1227 assert!(
1228 !out.contains("Affects Versions:"),
1229 "should omit affects versions header when None, got:\n{out}"
1230 );
1231 }
1232
1233 #[test]
1234 fn truncate_short_string() {
1235 assert_eq!(truncate("hello", 10), "hello");
1236 }
1237
1238 #[test]
1239 fn truncate_exact_length() {
1240 assert_eq!(truncate("hello", 5), "hello");
1241 }
1242
1243 #[test]
1244 fn truncate_long_string() {
1245 assert_eq!(truncate("hello world", 5), "hello…");
1246 }
1247
1248 #[test]
1249 fn truncate_multibyte_safe() {
1250 let result = truncate("日本語テスト", 3);
1251 assert_eq!(result, "日本語…");
1252 }
1253
1254 #[test]
1255 fn build_list_jql_empty() {
1256 assert_eq!(
1257 build_list_jql(&ListFilters::default()),
1258 "ORDER BY updated DESC"
1259 );
1260 }
1261
1262 #[test]
1263 fn build_list_jql_escapes_quotes() {
1264 let jql = build_list_jql(&ListFilters {
1265 status: Some(r#"Done" OR 1=1"#),
1266 ..Default::default()
1267 });
1268 assert!(jql.contains(r#"\""#), "double quote must be escaped");
1271 assert!(
1272 jql.contains(r#"status = "Done\""#),
1273 "escaped quote must remain inside the status value string"
1274 );
1275 }
1276
1277 #[test]
1278 fn build_list_jql_project_and_status() {
1279 let jql = build_list_jql(&ListFilters {
1280 project: Some("PROJ"),
1281 status: Some("In Progress"),
1282 ..Default::default()
1283 });
1284 assert!(jql.contains(r#"project = "PROJ""#));
1285 assert!(jql.contains(r#"status = "In Progress""#));
1286 }
1287
1288 #[test]
1289 fn build_list_jql_assignee_me() {
1290 let jql = build_list_jql(&ListFilters {
1291 assignee: Some("me"),
1292 ..Default::default()
1293 });
1294 assert!(jql.contains("currentUser()"));
1295 }
1296
1297 #[test]
1298 fn build_list_jql_issue_type() {
1299 let jql = build_list_jql(&ListFilters {
1300 issue_type: Some("Bug"),
1301 ..Default::default()
1302 });
1303 assert!(jql.contains(r#"issuetype = "Bug""#));
1304 }
1305
1306 #[test]
1307 fn build_list_jql_sprint_active() {
1308 let jql = build_list_jql(&ListFilters {
1309 sprint: Some("active"),
1310 ..Default::default()
1311 });
1312 assert!(jql.contains("sprint in openSprints()"));
1313 }
1314
1315 #[test]
1316 fn build_list_jql_sprint_named() {
1317 let jql = build_list_jql(&ListFilters {
1318 sprint: Some("Sprint 42"),
1319 ..Default::default()
1320 });
1321 assert!(jql.contains(r#"sprint = "Sprint 42""#));
1322 }
1323
1324 #[test]
1325 fn build_list_jql_single_component() {
1326 let jql = build_list_jql(&ListFilters {
1327 components: Some(&["Backend"]),
1328 ..Default::default()
1329 });
1330 assert!(
1331 jql.contains(r#"component = "Backend""#),
1332 "expected single-component clause, got: {jql}"
1333 );
1334 }
1335
1336 #[test]
1337 fn build_list_jql_multiple_components() {
1338 let jql = build_list_jql(&ListFilters {
1339 components: Some(&["Backend", "API"]),
1340 ..Default::default()
1341 });
1342 assert!(
1343 jql.contains(r#"component in ("Backend", "API")"#),
1344 "expected `component in (...)` clause, got: {jql}"
1345 );
1346 }
1347
1348 #[test]
1349 fn build_list_jql_escapes_component_quotes() {
1350 let jql = build_list_jql(&ListFilters {
1351 components: Some(&[r#"weird "name""#]),
1352 ..Default::default()
1353 });
1354 assert!(
1355 jql.contains(r#"component = "weird \"name\"""#),
1356 "expected escaped quotes, got: {jql}"
1357 );
1358 }
1359
1360 #[test]
1361 fn build_list_jql_empty_components_emits_no_clause() {
1362 let jql = build_list_jql(&ListFilters {
1363 components: Some(&[]),
1364 ..Default::default()
1365 });
1366 assert!(
1367 !jql.contains("component"),
1368 "expected no component clause for empty slice, got: {jql}"
1369 );
1370 }
1371
1372 #[test]
1373 fn build_list_jql_single_label() {
1374 let jql = build_list_jql(&ListFilters {
1375 labels: Some(&["backend"]),
1376 ..Default::default()
1377 });
1378 assert!(
1379 jql.contains(r#"labels = "backend""#),
1380 "expected single-label clause, got: {jql}"
1381 );
1382 }
1383
1384 #[test]
1385 fn build_list_jql_multiple_labels() {
1386 let jql = build_list_jql(&ListFilters {
1387 labels: Some(&["backend", "urgent"]),
1388 ..Default::default()
1389 });
1390 assert!(
1391 jql.contains(r#"labels in ("backend", "urgent")"#),
1392 "expected `labels in (...)` clause, got: {jql}"
1393 );
1394 }
1395
1396 #[test]
1397 fn build_list_jql_escapes_label_quotes() {
1398 let jql = build_list_jql(&ListFilters {
1399 labels: Some(&[r#"weird "name""#]),
1400 ..Default::default()
1401 });
1402 assert!(
1403 jql.contains(r#"labels = "weird \"name\"""#),
1404 "expected escaped quotes, got: {jql}"
1405 );
1406 }
1407
1408 #[test]
1409 fn build_list_jql_empty_labels_emits_no_clause() {
1410 let jql = build_list_jql(&ListFilters {
1411 labels: Some(&[]),
1412 ..Default::default()
1413 });
1414 assert!(
1415 !jql.contains("labels"),
1416 "expected no labels clause for empty slice, got: {jql}"
1417 );
1418 }
1419
1420 #[test]
1421 fn build_list_jql_single_fix_version() {
1422 let jql = build_list_jql(&ListFilters {
1423 fix_versions: Some(&["1.2.0"]),
1424 ..Default::default()
1425 });
1426 assert!(
1427 jql.contains(r#"fixVersion = "1.2.0""#),
1428 "expected single fixVersion clause, got: {jql}"
1429 );
1430 }
1431
1432 #[test]
1433 fn build_list_jql_multiple_fix_versions() {
1434 let jql = build_list_jql(&ListFilters {
1435 fix_versions: Some(&["1.2.0", "1.3.0"]),
1436 ..Default::default()
1437 });
1438 assert!(
1439 jql.contains(r#"fixVersion in ("1.2.0", "1.3.0")"#),
1440 "expected fixVersion in (...) clause, got: {jql}"
1441 );
1442 }
1443
1444 #[test]
1445 fn build_list_jql_escapes_fix_version_quotes() {
1446 let jql = build_list_jql(&ListFilters {
1447 fix_versions: Some(&[r#"weird "ver""#]),
1448 ..Default::default()
1449 });
1450 assert!(
1451 jql.contains(r#"fixVersion = "weird \"ver\"""#),
1452 "expected escaped quotes, got: {jql}"
1453 );
1454 }
1455
1456 #[test]
1457 fn build_list_jql_empty_fix_versions_emits_no_clause() {
1458 let jql = build_list_jql(&ListFilters {
1459 fix_versions: Some(&[]),
1460 ..Default::default()
1461 });
1462 assert!(
1463 !jql.contains("fixVersion"),
1464 "expected no fixVersion clause for empty slice, got: {jql}"
1465 );
1466 }
1467
1468 #[test]
1469 fn colorize_status_done_is_green() {
1470 let result = colorize_status("Done", "Done");
1471 assert!(result.contains("Done"));
1472 assert!(result.contains("\x1b["));
1474 }
1475
1476 #[test]
1477 fn colorize_status_unknown_unchanged() {
1478 let result = colorize_status("Backlog", "Backlog");
1479 assert_eq!(result, "Backlog");
1480 }
1481
1482 struct EnvVarGuard(&'static str);
1484
1485 impl Drop for EnvVarGuard {
1486 fn drop(&mut self) {
1487 unsafe { std::env::remove_var(self.0) }
1488 }
1489 }
1490
1491 #[test]
1492 fn terminal_width_fallback_parses_columns() {
1493 unsafe { std::env::set_var("COLUMNS", "200") };
1494 let _guard = EnvVarGuard("COLUMNS");
1495 assert_eq!(terminal_width(), 200);
1496 }
1497
1498 #[test]
1499 fn resolve_terminal_width_prefers_tty_over_columns() {
1500 assert_eq!(resolve_terminal_width(Some(200), Some(80)), 200);
1501 }
1502
1503 #[test]
1504 fn resolve_terminal_width_clamps_narrow_tty_to_minimum() {
1505 assert_eq!(resolve_terminal_width(Some(40), None), MIN_TERMINAL_WIDTH);
1506 }
1507
1508 #[test]
1509 fn resolve_terminal_width_does_not_clamp_columns_fallback() {
1510 assert_eq!(resolve_terminal_width(None, Some(40)), 40);
1514 }
1515
1516 #[test]
1517 fn resolve_terminal_width_defaults_when_nothing_available() {
1518 assert_eq!(resolve_terminal_width(None, None), DEFAULT_TERMINAL_WIDTH);
1519 }
1520
1521 #[tokio::test]
1522 async fn resolve_assignee_arg_absent_returns_none() {
1523 let server = wiremock::MockServer::start().await;
1524 let client = crate::api::JiraClient::new(
1525 &server.uri(),
1526 "test@example.com",
1527 "test-token",
1528 crate::api::AuthType::Basic,
1529 3,
1530 )
1531 .unwrap();
1532 let result = resolve_assignee_arg(&client, None).await.unwrap();
1533 assert!(result.is_none());
1534 }
1535
1536 #[tokio::test]
1537 async fn resolve_assignee_arg_none_sentinel_returns_some_none() {
1538 let server = wiremock::MockServer::start().await;
1539 let client = crate::api::JiraClient::new(
1540 &server.uri(),
1541 "test@example.com",
1542 "test-token",
1543 crate::api::AuthType::Basic,
1544 3,
1545 )
1546 .unwrap();
1547 let result = resolve_assignee_arg(&client, Some("none")).await.unwrap();
1548 assert!(matches!(result, Some(None)));
1549 }
1550
1551 #[tokio::test]
1552 async fn resolve_assignee_arg_literal_id_passes_through() {
1553 let server = wiremock::MockServer::start().await;
1554 let client = crate::api::JiraClient::new(
1555 &server.uri(),
1556 "test@example.com",
1557 "test-token",
1558 crate::api::AuthType::Basic,
1559 3,
1560 )
1561 .unwrap();
1562 let result = resolve_assignee_arg(&client, Some("literal-id-999"))
1563 .await
1564 .unwrap();
1565 assert_eq!(result, Some(Some("literal-id-999".to_string())));
1566 }
1567}