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