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