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