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