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