1#![allow(clippy::print_stdout)]
7
8use crate::cli::IssueCommand;
9use crate::client::Client;
10use crate::output::{format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13
14pub fn run(cmd: IssueCommand) -> anyhow::Result<()> {
20 match cmd {
21 IssueCommand::List {
22 owner_repo,
23 state,
24 labels,
25 assignee,
26 author,
27 mention,
28 milestone,
29 limit,
30 web,
31 json,
32 hostname,
33 } => list(
34 owner_repo,
35 &state,
36 &labels,
37 assignee.as_deref(),
38 author.as_deref(),
39 mention.as_deref(),
40 milestone.as_deref(),
41 limit,
42 web,
43 json,
44 hostname.as_deref(),
45 ),
46 IssueCommand::View {
47 number,
48 repo,
49 web,
50 comments,
51 json,
52 hostname,
53 } => view(
54 number,
55 repo.as_deref(),
56 web,
57 comments,
58 json,
59 hostname.as_deref(),
60 ),
61 IssueCommand::Close {
62 number,
63 repo,
64 comment,
65 reason,
66 hostname,
67 } => close(
68 number,
69 repo.as_deref(),
70 comment.as_deref(),
71 reason.as_deref(),
72 hostname.as_deref(),
73 ),
74 IssueCommand::Reopen {
75 number,
76 repo,
77 comment,
78 hostname,
79 } => reopen(
80 number,
81 repo.as_deref(),
82 comment.as_deref(),
83 hostname.as_deref(),
84 ),
85 IssueCommand::Comment {
86 number,
87 repo,
88 body,
89 body_file,
90 web,
91 hostname,
92 } => comment(
93 number,
94 repo.as_deref(),
95 body.as_deref(),
96 body_file.as_deref(),
97 web,
98 hostname.as_deref(),
99 ),
100 IssueCommand::Create {
101 repo,
102 title,
103 body,
104 labels,
105 assignee,
106 milestone,
107 project,
108 web,
109 hostname,
110 } => issue_create(
111 repo.as_deref(),
112 title.as_deref(),
113 body.as_deref(),
114 &labels,
115 &assignee,
116 milestone.as_deref(),
117 project,
118 web,
119 hostname.as_deref(),
120 ),
121 IssueCommand::Edit {
122 number,
123 repo,
124 title,
125 body,
126 add_label,
127 remove_label,
128 add_assignee,
129 remove_assignee,
130 milestone,
131 hostname,
132 } => edit(
133 number,
134 repo.as_deref(),
135 title.as_deref(),
136 body.as_deref(),
137 &add_label,
138 &remove_label,
139 &add_assignee,
140 &remove_assignee,
141 milestone.as_deref(),
142 hostname.as_deref(),
143 ),
144 IssueCommand::Transfer {
145 number,
146 destination,
147 repo,
148 hostname,
149 } => transfer(number, &destination, repo.as_deref(), hostname.as_deref()),
150 }
151}
152
153#[allow(clippy::too_many_arguments)]
163fn list(
164 owner_repo: Option<String>,
165 state: &str,
166 labels: &[String],
167 assignee: Option<&str>,
168 author: Option<&str>,
169 mention: Option<&str>,
170 milestone: Option<&str>,
171 limit: u32,
172 web: bool,
173 json: Option<Vec<String>>,
174 hostname: Option<&str>,
175) -> anyhow::Result<()> {
176 let spec = match owner_repo {
178 Some(s) => parse_repo_spec(&s).context("invalid repository spec")?,
179 None => detect_remote().ok_or_else(|| {
180 anyhow::anyhow!(
181 "could not detect repository from current directory; specify OWNER/REPO"
182 )
183 })?,
184 };
185
186 let host = hostname.unwrap_or("github.com");
187
188 if web {
190 let web_url = format!("https://{host}/{}/{}/issues", spec.owner, spec.repo);
191 open_in_browser(&web_url);
192 return Ok(());
193 }
194
195 let client = Client::new(host).context("failed to create HTTP client")?;
196
197 let mut query_params = vec![
199 ("state", state.to_string()),
200 ("per_page", limit.min(100).to_string()),
201 ];
202
203 if !labels.is_empty() {
204 query_params.push(("labels", labels.join(",")));
205 }
206 if let Some(a) = assignee {
207 query_params.push(("assignee", (*a).to_string()));
208 }
209 if let Some(a) = author {
210 query_params.push(("creator", (*a).to_string()));
212 }
213 if let Some(m) = mention {
214 query_params.push(("mentioned", (*m).to_string()));
215 }
216 if let Some(m) = milestone {
217 query_params.push(("milestone", (*m).to_string()));
218 }
219
220 let query_string = query_params
221 .iter()
222 .map(|(k, v)| format!("{k}={v}"))
223 .collect::<Vec<_>>()
224 .join("&");
225
226 let path = format!("/repos/{}/{}/issues?{query_string}", spec.owner, spec.repo);
227
228 let response = client.get(&path).context("failed to fetch issues")?;
229
230 let status = response.status();
231 if status == reqwest::StatusCode::NOT_FOUND {
232 anyhow::bail!("repository '{spec}' not found");
233 }
234 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
235 anyhow::bail!("authentication required to list issues for '{spec}'");
236 }
237 if !status.is_success() {
238 anyhow::bail!("failed to list issues for '{spec}': HTTP {status}");
239 }
240
241 let mut issues: Vec<serde_json::Value> =
242 response.json().context("failed to parse issue response")?;
243
244 if let Some(a) = author {
246 issues.retain(|issue| {
247 issue["user"]["login"]
248 .as_str()
249 .is_some_and(|login| login.eq_ignore_ascii_case(a))
250 });
251 }
252 if !labels.is_empty() {
253 issues.retain(|issue| {
254 let issue_labels: Vec<&str> = issue["labels"]
255 .as_array()
256 .map(|arr| arr.iter().filter_map(|l| l["name"].as_str()).collect())
257 .unwrap_or_default();
258 labels
259 .iter()
260 .all(|label| issue_labels.iter().any(|l| l.eq_ignore_ascii_case(label)))
261 });
262 }
263 if let Some(a) = assignee {
264 issues.retain(|issue| {
265 issue["assignees"].as_array().is_some_and(|arr| {
266 arr.iter().any(|assignee| {
267 assignee["login"]
268 .as_str()
269 .is_some_and(|login| login.eq_ignore_ascii_case(a))
270 })
271 })
272 });
273 }
274 if let Some(m) = mention {
275 issues.retain(|issue| {
276 let body = issue["body"].as_str().unwrap_or("");
277 body.to_lowercase().contains(&m.to_lowercase())
278 });
279 }
280 if let Some(m) = milestone {
281 issues.retain(|issue| {
282 issue["milestone"]["title"]
283 .as_str()
284 .is_some_and(|title| title.eq_ignore_ascii_case(m))
285 });
286 }
287
288 if let Some(fields) = json {
290 let fields_ref: Option<&[String]> = if fields.is_empty() {
291 None
292 } else {
293 Some(&fields)
294 };
295 print_json(&issues, fields_ref);
296 return Ok(());
297 }
298
299 print_issue_table(&issues);
301 Ok(())
302}
303
304#[allow(clippy::too_many_arguments)]
315fn view(
316 number: u64,
317 repo: Option<&str>,
318 web: bool,
319 comments: bool,
320 json: Option<Vec<String>>,
321 hostname: Option<&str>,
322) -> anyhow::Result<()> {
323 let spec = match repo {
325 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
326 None => detect_remote().ok_or_else(|| {
327 anyhow::anyhow!(
328 "could not detect repository from current directory; specify OWNER/REPO"
329 )
330 })?,
331 };
332
333 let host = hostname.unwrap_or("github.com");
334
335 if web {
337 let web_url = format!(
338 "https://{host}/{}/{}/issues/{number}",
339 spec.owner, spec.repo
340 );
341 open_in_browser(&web_url);
342 return Ok(());
343 }
344
345 let client = Client::new(host).context("failed to create HTTP client")?;
346
347 let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
349 let response = client.get(&path).context("failed to fetch issue")?;
350
351 let status = response.status();
352 if status == reqwest::StatusCode::NOT_FOUND {
353 anyhow::bail!("issue #{number} not found in '{spec}'");
354 }
355 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
356 anyhow::bail!("authentication required to view issue #{number}");
357 }
358 if !status.is_success() {
359 anyhow::bail!("failed to view issue #{number}: HTTP {status}");
360 }
361
362 let issue: serde_json::Value = response.json().context("failed to parse issue response")?;
363
364 if let Some(fields) = json {
366 let fields_ref: Option<&[String]> = if fields.is_empty() {
367 None
368 } else {
369 Some(&fields)
370 };
371 print_json(&issue, fields_ref);
372 return Ok(());
373 }
374
375 let comments_data = if comments {
377 let comments_path = format!(
378 "/repos/{}/{}/issues/{number}/comments",
379 spec.owner, spec.repo
380 );
381 client
382 .get(&comments_path)
383 .ok()
384 .and_then(|r| r.json().ok())
385 .unwrap_or_default()
386 } else {
387 Vec::<serde_json::Value>::new()
388 };
389
390 print_issue_view(&issue, &comments_data);
392 Ok(())
393}
394
395fn close(
405 number: u64,
406 repo: Option<&str>,
407 comment: Option<&str>,
408 reason: Option<&str>,
409 hostname: Option<&str>,
410) -> anyhow::Result<()> {
411 let spec = match repo {
412 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
413 None => detect_remote().ok_or_else(|| {
414 anyhow::anyhow!(
415 "could not detect repository from current directory; specify OWNER/REPO"
416 )
417 })?,
418 };
419
420 let host = hostname.unwrap_or("github.com");
421 let client = Client::new(host).context("failed to create HTTP client")?;
422
423 let mut body = serde_json::json!({
425 "state": "closed",
426 });
427
428 if let Some(r) = reason {
429 body["state_reason"] = serde_json::Value::String(r.to_string());
430 }
431
432 let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
433 let response = client
434 .request("PATCH", &path, &[], Some(serde_json::to_vec(&body)?))
435 .context("failed to close issue")?;
436
437 let status = response.status();
438 if status == reqwest::StatusCode::NOT_FOUND {
439 anyhow::bail!("issue #{number} not found in '{spec}'");
440 }
441 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
442 anyhow::bail!("authentication required to close issue #{number}");
443 }
444 if !status.is_success() {
445 anyhow::bail!("failed to close issue #{number}: HTTP {status}");
446 }
447
448 if let Some(c) = comment {
450 let comment_path = format!(
451 "/repos/{}/{}/issues/{number}/comments",
452 spec.owner, spec.repo
453 );
454 let comment_body = serde_json::json!({ "body": c });
455 let comment_response = client
456 .request(
457 "POST",
458 &comment_path,
459 &[],
460 Some(serde_json::to_vec(&comment_body)?),
461 )
462 .context("failed to add closing comment")?;
463 if !comment_response.status().is_success() {
464 tracing::warn!(
465 "failed to add closing comment: HTTP {}",
466 comment_response.status()
467 );
468 }
469 }
470
471 let reason_str = reason.map_or_else(String::new, |r| format!(" as {r}"));
472 println!("✓ issue #{number} closed{reason_str}");
473 Ok(())
474}
475
476fn reopen(
486 number: u64,
487 repo: Option<&str>,
488 comment: Option<&str>,
489 hostname: Option<&str>,
490) -> anyhow::Result<()> {
491 let spec = match repo {
492 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
493 None => detect_remote().ok_or_else(|| {
494 anyhow::anyhow!(
495 "could not detect repository from current directory; specify OWNER/REPO"
496 )
497 })?,
498 };
499
500 let host = hostname.unwrap_or("github.com");
501 let client = Client::new(host).context("failed to create HTTP client")?;
502
503 let body = serde_json::json!({
504 "state": "open",
505 });
506
507 let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
508 let response = client
509 .request("PATCH", &path, &[], Some(serde_json::to_vec(&body)?))
510 .context("failed to reopen issue")?;
511
512 let status = response.status();
513 if status == reqwest::StatusCode::NOT_FOUND {
514 anyhow::bail!("issue #{number} not found in '{spec}'");
515 }
516 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
517 anyhow::bail!("authentication required to reopen issue #{number}");
518 }
519 if !status.is_success() {
520 anyhow::bail!("failed to reopen issue #{number}: HTTP {status}");
521 }
522
523 if let Some(c) = comment {
525 let comment_path = format!(
526 "/repos/{}/{}/issues/{number}/comments",
527 spec.owner, spec.repo
528 );
529 let comment_body = serde_json::json!({ "body": c });
530 let comment_response = client
531 .request(
532 "POST",
533 &comment_path,
534 &[],
535 Some(serde_json::to_vec(&comment_body)?),
536 )
537 .context("failed to add comment")?;
538 if !comment_response.status().is_success() {
539 tracing::warn!("failed to add comment: HTTP {}", comment_response.status());
540 }
541 }
542
543 println!("✓ issue #{number} reopened");
544 Ok(())
545}
546
547fn comment(
557 number: u64,
558 repo: Option<&str>,
559 body: Option<&str>,
560 body_file: Option<&str>,
561 web: bool,
562 hostname: Option<&str>,
563) -> anyhow::Result<()> {
564 let spec = match repo {
565 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
566 None => detect_remote().ok_or_else(|| {
567 anyhow::anyhow!(
568 "could not detect repository from current directory; specify OWNER/REPO"
569 )
570 })?,
571 };
572
573 let host = hostname.unwrap_or("github.com");
574
575 if web {
577 let web_url = format!(
578 "https://{host}/{}/{}/issues/{number}",
579 spec.owner, spec.repo
580 );
581 open_in_browser(&web_url);
582 return Ok(());
583 }
584
585 let comment_body = match (body, body_file) {
587 (Some(b), None) => b.to_string(),
588 (None, Some(f)) => {
589 if f == "@-" {
590 let mut buf = String::new();
591 std::io::stdin()
592 .read_line(&mut buf)
593 .context("failed to read from stdin")?;
594 buf
595 } else {
596 std::fs::read_to_string(f)
597 .with_context(|| format!("failed to read body file '{f}'"))?
598 }
599 }
600 (None, None) => anyhow::bail!("either --body or --body-file is required"),
601 (Some(_), Some(_)) => unreachable!(), };
603
604 let client = Client::new(host).context("failed to create HTTP client")?;
605
606 let path = format!(
607 "/repos/{}/{}/issues/{number}/comments",
608 spec.owner, spec.repo
609 );
610 let request_body = serde_json::json!({ "body": comment_body });
611 let response = client
612 .request("POST", &path, &[], Some(serde_json::to_vec(&request_body)?))
613 .context("failed to post comment")?;
614
615 let status = response.status();
616 if status == reqwest::StatusCode::NOT_FOUND {
617 anyhow::bail!("issue #{number} not found in '{spec}'");
618 }
619 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
620 anyhow::bail!("authentication required to comment on issue #{number}");
621 }
622 if !status.is_success() {
623 anyhow::bail!("failed to comment on issue #{number}: HTTP {status}");
624 }
625
626 let comment_json: serde_json::Value = response
627 .json()
628 .context("failed to parse comment response")?;
629
630 let html_url = comment_json["html_url"]
631 .as_str()
632 .unwrap_or("https://github.com/");
633 println!("✓ comment posted: {html_url}");
634 Ok(())
635}
636
637#[allow(clippy::too_many_arguments)]
647fn issue_create(
648 repo: Option<&str>,
649 title: Option<&str>,
650 body: Option<&str>,
651 labels: &[String],
652 assignees: &[String],
653 _milestone: Option<&str>,
654 _project: Option<u32>,
655 web: bool,
656 hostname: Option<&str>,
657) -> anyhow::Result<()> {
658 let spec = match repo {
659 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
660 None => detect_remote().ok_or_else(|| {
661 anyhow::anyhow!(
662 "could not detect repository from current directory; specify OWNER/REPO with --repo"
663 )
664 })?,
665 };
666
667 let host = hostname.unwrap_or("github.com");
668 let client = Client::new(host).context("failed to create HTTP client")?;
669
670 let mut body_map = serde_json::Map::new();
672 body_map.insert(
673 "title".to_string(),
674 serde_json::Value::String(
675 title
676 .ok_or_else(|| anyhow::anyhow!("issue title is required; use --title"))?
677 .to_string(),
678 ),
679 );
680 if let Some(b) = body {
681 body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
682 }
683 if !labels.is_empty() {
684 body_map.insert(
685 "labels".to_string(),
686 serde_json::Value::Array(
687 labels
688 .iter()
689 .map(|l| serde_json::Value::String(l.clone()))
690 .collect(),
691 ),
692 );
693 }
694 if !assignees.is_empty() {
695 body_map.insert(
696 "assignees".to_string(),
697 serde_json::Value::Array(
698 assignees
699 .iter()
700 .map(|a| serde_json::Value::String(a.clone()))
701 .collect(),
702 ),
703 );
704 }
705
706 let body_value = serde_json::Value::Object(body_map);
707
708 let path = format!("/repos/{}/{}/issues", spec.owner, spec.repo);
710 let response = client
711 .post(&path, &body_value)
712 .context("failed to create issue")?;
713
714 let status = response.status();
715 if status == reqwest::StatusCode::NOT_FOUND {
716 anyhow::bail!("repository '{spec}' not found");
717 }
718 if !status.is_success() {
719 let err_body: serde_json::Value = response.json().unwrap_or_default();
720 let msg = err_body["message"].as_str().unwrap_or("creation failed");
721 anyhow::bail!("failed to create issue: {msg}");
722 }
723
724 let issue: serde_json::Value = response.json().context("failed to parse issue response")?;
725
726 let issue_number = issue["number"].as_u64().unwrap_or(0);
727 let issue_url = issue["html_url"].as_str().unwrap_or("");
728
729 if web && !issue_url.is_empty() {
731 open_in_browser(issue_url);
732 }
733
734 println!(
735 "https://github.com/{}/{}/issues/{issue_number}",
736 spec.owner, spec.repo
737 );
738 Ok(())
739}
740
741#[allow(clippy::too_many_arguments)]
751fn edit(
752 number: u64,
753 repo: Option<&str>,
754 title: Option<&str>,
755 body: Option<&str>,
756 add_label: &[String],
757 remove_label: &[String],
758 add_assignee: &[String],
759 remove_assignee: &[String],
760 _milestone: Option<&str>,
761 hostname: Option<&str>,
762) -> anyhow::Result<()> {
763 let spec = match repo {
764 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
765 None => detect_remote().ok_or_else(|| {
766 anyhow::anyhow!(
767 "could not detect repository from current directory; specify OWNER/REPO with --repo"
768 )
769 })?,
770 };
771
772 let host = hostname.unwrap_or("github.com");
773 let client = Client::new(host).context("failed to create HTTP client")?;
774
775 let mut body_map = serde_json::Map::new();
777
778 if let Some(t) = title {
779 body_map.insert(
780 "title".to_string(),
781 serde_json::Value::String(t.to_string()),
782 );
783 }
784 if let Some(b) = body {
785 body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
786 }
787
788 if !add_label.is_empty() || !remove_label.is_empty() {
790 let get_path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
792 let current: serde_json::Value = client
793 .get(&get_path)
794 .context("failed to fetch current issue labels")?
795 .json()
796 .context("failed to parse issue response")?;
797
798 let current_labels: Vec<String> = current["labels"]
799 .as_array()
800 .map(|arr| {
801 arr.iter()
802 .filter_map(|l| l["name"].as_str().map(String::from))
803 .collect()
804 })
805 .unwrap_or_default();
806
807 let mut new_labels = current_labels;
808 for label in add_label {
810 if !new_labels.contains(label) {
811 new_labels.push(label.clone());
812 }
813 }
814 new_labels.retain(|l| !remove_label.contains(l));
816
817 body_map.insert(
818 "labels".to_string(),
819 serde_json::Value::Array(
820 new_labels
821 .iter()
822 .map(|l| serde_json::Value::String(l.clone()))
823 .collect(),
824 ),
825 );
826 }
827
828 if !add_assignee.is_empty() || !remove_assignee.is_empty() {
830 if !add_assignee.is_empty() || !remove_assignee.is_empty() {
832 let get_path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
833 let current: serde_json::Value = client
834 .get(&get_path)
835 .context("failed to fetch current assignees")?
836 .json()
837 .context("failed to parse issue response")?;
838
839 let current_assignees: Vec<String> = current["assignees"]
840 .as_array()
841 .map(|arr| {
842 arr.iter()
843 .filter_map(|l| l["login"].as_str().map(String::from))
844 .collect()
845 })
846 .unwrap_or_default();
847
848 let mut new_assignees = current_assignees;
849 for a in add_assignee {
850 if !new_assignees.contains(a) {
851 new_assignees.push(a.clone());
852 }
853 }
854 new_assignees.retain(|a| !remove_assignee.contains(a));
855
856 body_map.insert(
857 "assignees".to_string(),
858 serde_json::Value::Array(
859 new_assignees
860 .iter()
861 .map(|a| serde_json::Value::String(a.clone()))
862 .collect(),
863 ),
864 );
865 }
866 }
867
868 let body_value = serde_json::Value::Object(body_map);
869
870 let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
871 let response = client
872 .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
873 .context("failed to update issue")?;
874
875 let status = response.status();
876 if status == reqwest::StatusCode::NOT_FOUND {
877 anyhow::bail!("issue #{number} not found in '{spec}'");
878 }
879 if !status.is_success() {
880 let err_body: serde_json::Value = response.json().unwrap_or_default();
881 let msg = err_body["message"].as_str().unwrap_or("update failed");
882 anyhow::bail!("failed to update issue #{number}: {msg}");
883 }
884
885 let updated: serde_json::Value = response
886 .json()
887 .context("failed to parse updated issue response")?;
888
889 let issue_number = updated["number"].as_u64().unwrap_or(number);
890 println!(
891 "https://github.com/{}/{}/issues/{issue_number}",
892 spec.owner, spec.repo
893 );
894 Ok(())
895}
896
897fn transfer(
906 number: u64,
907 destination: &str,
908 repo: Option<&str>,
909 hostname: Option<&str>,
910) -> anyhow::Result<()> {
911 let host = hostname.unwrap_or("github.com");
912 let client = Client::new(host).context("failed to create HTTP client")?;
913
914 let spec = if let Some(r) = repo {
916 parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
917 } else {
918 detect_remote().context("could not detect repository from git remote")?
919 };
920
921 let dest_spec = parse_repo_spec(destination)
923 .with_context(|| format!("invalid destination repository: {destination}"))?;
924
925 let path = format!(
926 "/repos/{}/{}/issues/{number}/transfer",
927 spec.owner, spec.repo
928 );
929
930 let body = serde_json::json!({
931 "new_owner": dest_spec.owner,
932 "new_repo": dest_spec.repo,
933 });
934
935 let response = client
936 .post(&path, &body)
937 .context("failed to transfer issue")?;
938
939 let status = response.status();
940 if !status.is_success() {
941 let err_body: serde_json::Value = response.json().unwrap_or_default();
942 let msg = err_body["message"].as_str().unwrap_or("transfer failed");
943 anyhow::bail!("failed to transfer issue #{number}: {msg}");
944 }
945
946 let result: serde_json::Value = response.json().context("failed to parse response")?;
947 let new_url = result["html_url"].as_str().unwrap_or("—");
948 println!("Issue #{number} transferred to {destination}: {new_url}");
949 Ok(())
950}
951
952fn print_issue_view(issue: &serde_json::Value, comments: &[serde_json::Value]) {
957 let title = issue["title"].as_str().unwrap_or("(no title)");
959 println!("{title}");
960 let separator_len = title.len().min(80);
961 println!("{}", "─".repeat(separator_len));
962 println!();
963
964 let state = issue["state"].as_str().unwrap_or("unknown");
966 let author = issue["user"]["login"].as_str().unwrap_or("unknown");
967 let created = issue["created_at"]
968 .as_str()
969 .map_or_else(|| "—".to_string(), format_date);
970 let updated = issue["updated_at"]
971 .as_str()
972 .map_or_else(|| "—".to_string(), format_date);
973
974 println!("State: {state}");
975 println!("Author: {author}");
976 println!("Created: {created}");
977 println!("Updated: {updated}");
978
979 let labels_str = issue["labels"]
981 .as_array()
982 .map(|arr| {
983 arr.iter()
984 .filter_map(|l| l["name"].as_str())
985 .collect::<Vec<_>>()
986 .join(", ")
987 })
988 .unwrap_or_default();
989 if !labels_str.is_empty() {
990 println!("Labels: {labels_str}");
991 }
992
993 let assignees_str = issue["assignees"]
995 .as_array()
996 .map(|arr| {
997 arr.iter()
998 .filter_map(|a| a["login"].as_str())
999 .collect::<Vec<_>>()
1000 .join(", ")
1001 })
1002 .unwrap_or_default();
1003 if !assignees_str.is_empty() {
1004 println!("Assignees: {assignees_str}");
1005 }
1006
1007 if let Some(milestone_title) = issue["milestone"]["title"].as_str() {
1009 println!("Milestone: {milestone_title}");
1010 }
1011
1012 println!();
1013
1014 let body = issue["body"].as_str().unwrap_or("");
1016 if !body.is_empty() {
1017 println!("{body}");
1018 println!();
1019 }
1020
1021 if !comments.is_empty() {
1023 println!("── Comments ──");
1024 println!();
1025 for comment in comments {
1026 let comment_author = comment["user"]["login"].as_str().unwrap_or("unknown");
1027 let comment_date = comment["created_at"]
1028 .as_str()
1029 .map_or_else(|| "—".to_string(), format_date);
1030 let comment_body = comment["body"].as_str().unwrap_or("");
1031 println!("{comment_author} commented on {comment_date}");
1032 println!();
1033 println!("{comment_body}");
1034 println!();
1035 }
1036 }
1037}
1038
1039fn print_issue_table(issues: &[serde_json::Value]) {
1043 if issues.is_empty() {
1044 println!("No issues found.");
1045 return;
1046 }
1047
1048 let num_width = 8;
1050 let title_width = 50;
1051 let author_width = 14;
1052 let labels_width = 14;
1053 let state_width = 8;
1054
1055 println!(
1057 "{:>num_width$} {:<title_width$} {:<author_width$} {:<labels_width$} {:<state_width$}",
1058 "NUMBER", "TITLE", "AUTHOR", "LABELS", "STATE",
1059 );
1060
1061 for issue in issues {
1062 let number = issue["number"]
1063 .as_u64()
1064 .map_or_else(|| "—".to_string(), |n| n.to_string());
1065 let title = issue["title"].as_str().unwrap_or("—");
1066 let author = issue["user"]["login"].as_str().unwrap_or("—");
1067 let state = issue["state"].as_str().unwrap_or("—");
1068
1069 let labels_str = issue["labels"]
1070 .as_array()
1071 .map(|arr| {
1072 arr.iter()
1073 .filter_map(|l| l["name"].as_str())
1074 .collect::<Vec<_>>()
1075 .join(", ")
1076 })
1077 .unwrap_or_default();
1078 let labels_display = if labels_str.is_empty() {
1079 "—".to_string()
1080 } else {
1081 labels_str
1082 };
1083
1084 let title_truncated = crate::cmd::util::truncate(title, title_width);
1085 let author_truncated = crate::cmd::util::truncate(author, author_width);
1086 let labels_truncated = crate::cmd::util::truncate(&labels_display, labels_width);
1087
1088 println!(
1089 "{number:>num_width$} {title_truncated:<title_width$} {author_truncated:<author_width$} {labels_truncated:<labels_width$} {state:<state_width$}",
1090 );
1091 }
1092}
1093
1094fn open_in_browser(url: &str) {
1096 #[cfg(target_os = "linux")]
1097 {
1098 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1099 }
1100 #[cfg(target_os = "macos")]
1101 {
1102 let _ = std::process::Command::new("open").arg(url).spawn();
1103 }
1104 #[cfg(target_os = "windows")]
1105 {
1106 let _ = std::process::Command::new("cmd")
1107 .args(["/c", "start", url])
1108 .spawn();
1109 }
1110 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1111 {
1112 println!("Open {url} in your browser");
1113 }
1114}
1115
1116#[cfg(test)]
1117#[allow(clippy::expect_used)]
1118mod tests {
1119 use super::*;
1120 use serde_json::json;
1121
1122 #[test]
1123 fn print_issue_table_basic() {
1124 let issues = vec![json!({
1125 "number": 42,
1126 "title": "Fix authentication bug in login flow",
1127 "state": "open",
1128 "user": { "login": "octocat" },
1129 "labels": [
1130 { "name": "bug" },
1131 { "name": "security" }
1132 ]
1133 })];
1134 print_issue_table(&issues);
1136 }
1137
1138 #[test]
1139 fn print_issue_table_closed() {
1140 let issues = vec![json!({
1141 "number": 100,
1142 "title": "Add new feature",
1143 "state": "closed",
1144 "user": { "login": "dev-user" },
1145 "labels": []
1146 })];
1147 print_issue_table(&issues);
1149 }
1150
1151 #[test]
1152 fn print_issue_table_empty() {
1153 let issues: Vec<serde_json::Value> = vec![];
1154 print_issue_table(&issues);
1156 }
1157
1158 #[test]
1159 fn print_issue_table_multiple() {
1160 let issues = vec![
1161 json!({
1162 "number": 1,
1163 "title": "First issue",
1164 "state": "open",
1165 "user": { "login": "alice" },
1166 "labels": [{"name": "enhancement"}]
1167 }),
1168 json!({
1169 "number": 2,
1170 "title": "Second issue with a very long title that should be truncated in the table output",
1171 "state": "closed",
1172 "user": { "login": "bob" },
1173 "labels": [{"name": "bug"}, {"name": "docs"}]
1174 }),
1175 ];
1176 print_issue_table(&issues);
1178 }
1179
1180 #[test]
1181 fn print_issue_table_null_fields() {
1182 let issues = vec![json!({
1183 "number": 99,
1184 "title": null,
1185 "state": null,
1186 "user": null,
1187 "labels": null
1188 })];
1189 print_issue_table(&issues);
1191 }
1192
1193 #[test]
1194 fn open_in_browser_does_not_panic() {
1195 open_in_browser("https://github.com/octocat/hello-world/issues");
1197 }
1198
1199 #[test]
1200 fn print_issue_view_basic() {
1201 let issue = json!({
1202 "number": 42,
1203 "title": "Fix authentication bug",
1204 "state": "open",
1205 "user": { "login": "octocat" },
1206 "created_at": "2024-01-15T10:30:00Z",
1207 "updated_at": "2024-01-16T12:00:00Z",
1208 "body": "This issue describes an authentication bug.",
1209 "labels": [
1210 { "name": "bug" },
1211 { "name": "security" }
1212 ],
1213 "assignees": [
1214 { "login": "alice" }
1215 ],
1216 "milestone": { "title": "v1.0" }
1217 });
1218 let comments: Vec<serde_json::Value> = vec![];
1219 print_issue_view(&issue, &comments);
1221 }
1222
1223 #[test]
1224 fn print_issue_view_closed() {
1225 let issue = json!({
1226 "number": 100,
1227 "title": "Add new feature",
1228 "state": "closed",
1229 "user": { "login": "dev-user" },
1230 "created_at": "2024-01-10T08:00:00Z",
1231 "updated_at": "2024-01-15T10:30:00Z",
1232 "body": "This adds a new feature.",
1233 "labels": [],
1234 "assignees": [],
1235 "milestone": null
1236 });
1237 let comments: Vec<serde_json::Value> = vec![];
1238 print_issue_view(&issue, &comments);
1240 }
1241
1242 #[test]
1243 fn print_issue_view_with_comments() {
1244 let issue = json!({
1245 "number": 42,
1246 "title": "Fix bug",
1247 "state": "open",
1248 "user": { "login": "octocat" },
1249 "created_at": "2024-01-15T10:30:00Z",
1250 "updated_at": "2024-01-16T12:00:00Z",
1251 "body": "Fixes a bug.",
1252 "labels": [],
1253 "assignees": [],
1254 "milestone": null
1255 });
1256 let comments = vec![
1257 json!({
1258 "user": { "login": "reviewer1" },
1259 "created_at": "2024-01-16T14:00:00Z",
1260 "body": "Looks good to me!"
1261 }),
1262 json!({
1263 "user": { "login": "octocat" },
1264 "created_at": "2024-01-16T15:00:00Z",
1265 "body": "Thanks for the review!"
1266 }),
1267 ];
1268 print_issue_view(&issue, &comments);
1270 }
1271
1272 #[test]
1273 fn print_issue_view_null_fields() {
1274 let issue = json!({
1275 "number": 99,
1276 "title": null,
1277 "state": null,
1278 "user": null,
1279 "created_at": null,
1280 "updated_at": null,
1281 "body": null,
1282 "labels": null,
1283 "assignees": null,
1284 "milestone": null
1285 });
1286 let comments: Vec<serde_json::Value> = vec![];
1287 print_issue_view(&issue, &comments);
1289 }
1290
1291 #[test]
1292 fn print_issue_view_with_assignees() {
1293 let issue = json!({
1294 "number": 50,
1295 "title": "Multiple assignees",
1296 "state": "open",
1297 "user": { "login": "owner" },
1298 "created_at": "2024-02-01T10:00:00Z",
1299 "updated_at": "2024-02-02T10:00:00Z",
1300 "body": "This issue has multiple assignees.",
1301 "labels": [],
1302 "assignees": [
1303 { "login": "alice" },
1304 { "login": "bob" },
1305 { "login": "carol" }
1306 ],
1307 "milestone": null
1308 });
1309 let comments: Vec<serde_json::Value> = vec![];
1310 print_issue_view(&issue, &comments);
1312 }
1313
1314 #[test]
1315 fn print_issue_view_with_milestone() {
1316 let issue = json!({
1317 "number": 60,
1318 "title": "Milestone issue",
1319 "state": "open",
1320 "user": { "login": "planner" },
1321 "created_at": "2024-03-01T10:00:00Z",
1322 "updated_at": "2024-03-02T10:00:00Z",
1323 "body": "This issue is part of a milestone.",
1324 "labels": [{"name": "enhancement"}],
1325 "assignees": [],
1326 "milestone": { "title": "v2.0" }
1327 });
1328 let comments: Vec<serde_json::Value> = vec![];
1329 print_issue_view(&issue, &comments);
1331 }
1332}