1#![allow(clippy::print_stdout, clippy::print_stderr)]
7
8use crate::cli::PrCommand;
9use crate::client::Client;
10use crate::output::{format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13use gix::bstr::ByteSlice;
14use std::collections::BTreeMap;
15use std::io::Write;
16
17pub fn run(cmd: PrCommand) -> anyhow::Result<()> {
23 match cmd {
24 PrCommand::List {
25 owner_repo,
26 state,
27 base,
28 head,
29 author,
30 labels,
31 assignee,
32 limit,
33 web,
34 json,
35 hostname,
36 } => list(
37 owner_repo,
38 &state,
39 base.as_deref(),
40 head.as_deref(),
41 author.as_deref(),
42 &labels,
43 assignee.as_deref(),
44 limit,
45 web,
46 json,
47 hostname.as_deref(),
48 ),
49 PrCommand::View {
50 number,
51 repo,
52 web,
53 comments,
54 json,
55 hostname,
56 } => view(
57 number,
58 repo.as_deref(),
59 web,
60 comments,
61 json,
62 hostname.as_deref(),
63 ),
64 PrCommand::Create {
65 repo,
66 title,
67 body,
68 base,
69 head,
70 draft,
71 labels,
72 assignee,
73 milestone,
74 project,
75 web,
76 hostname,
77 } => create(
78 repo.as_deref(),
79 title.as_deref(),
80 body.as_deref(),
81 base.as_deref(),
82 head.as_deref(),
83 draft,
84 &labels,
85 &assignee,
86 milestone.as_deref(),
87 project,
88 web,
89 hostname.as_deref(),
90 ),
91 PrCommand::Close {
92 number,
93 repo,
94 comment,
95 hostname,
96 } => close(
97 number,
98 repo.as_deref(),
99 comment.as_deref(),
100 hostname.as_deref(),
101 ),
102 PrCommand::Reopen {
103 number,
104 repo,
105 comment,
106 hostname,
107 } => reopen(
108 number,
109 repo.as_deref(),
110 comment.as_deref(),
111 hostname.as_deref(),
112 ),
113 PrCommand::Comment {
114 number,
115 repo,
116 body,
117 body_file,
118 web,
119 hostname,
120 } => pr_comment(
121 number,
122 repo.as_deref(),
123 body.as_deref(),
124 body_file.as_deref(),
125 web,
126 hostname.as_deref(),
127 ),
128 PrCommand::Merge {
129 number,
130 repo,
131 merge,
132 squash,
133 rebase,
134 body,
135 subject,
136 delete_branch,
137 admin,
138 auto,
139 hostname,
140 } => pr_merge(
141 number,
142 repo.as_deref(),
143 merge,
144 squash,
145 rebase,
146 body.as_deref(),
147 subject.as_deref(),
148 delete_branch,
149 admin,
150 auto,
151 hostname.as_deref(),
152 ),
153 PrCommand::Checkout {
154 number,
155 repo,
156 branch,
157 recurse_submodules,
158 hostname,
159 } => pr_checkout(
160 number,
161 repo.as_deref(),
162 branch.as_deref(),
163 recurse_submodules,
164 hostname.as_deref(),
165 ),
166 PrCommand::Diff {
167 number,
168 repo,
169 color,
170 name_only,
171 hostname,
172 } => diff(
173 number,
174 repo.as_deref(),
175 &color,
176 name_only,
177 hostname.as_deref(),
178 ),
179 PrCommand::Edit {
180 number,
181 repo,
182 title,
183 body,
184 base,
185 add_label,
186 remove_label,
187 add_assignee,
188 remove_assignee,
189 milestone,
190 hostname,
191 } => pr_edit(
192 number,
193 repo.as_deref(),
194 title.as_deref(),
195 body.as_deref(),
196 base.as_deref(),
197 &add_label,
198 &remove_label,
199 &add_assignee,
200 &remove_assignee,
201 milestone.as_deref(),
202 hostname.as_deref(),
203 ),
204 PrCommand::Review {
205 number,
206 repo,
207 approve,
208 request_changes,
209 comment,
210 body,
211 hostname,
212 } => review(
213 number,
214 repo.as_deref(),
215 approve,
216 request_changes,
217 comment,
218 body.as_deref(),
219 hostname.as_deref(),
220 ),
221 PrCommand::Checks {
222 number,
223 repo,
224 watch,
225 json,
226 hostname,
227 } => checks(number, repo.as_deref(), watch, json, hostname.as_deref()),
228 PrCommand::Ready {
229 number,
230 repo,
231 hostname,
232 } => ready(number, repo.as_deref(), hostname.as_deref()),
233 }
234}
235
236#[allow(clippy::too_many_arguments)]
246fn list(
247 owner_repo: Option<String>,
248 state: &str,
249 base: Option<&str>,
250 head: Option<&str>,
251 author: Option<&str>,
252 labels: &[String],
253 assignee: Option<&str>,
254 limit: u32,
255 web: bool,
256 json: Option<Vec<String>>,
257 hostname: Option<&str>,
258) -> anyhow::Result<()> {
259 let spec = match owner_repo {
261 Some(s) => parse_repo_spec(&s).context("invalid repository spec")?,
262 None => detect_remote().ok_or_else(|| {
263 anyhow::anyhow!(
264 "could not detect repository from current directory; specify OWNER/REPO"
265 )
266 })?,
267 };
268
269 let host = hostname.unwrap_or("github.com");
270
271 if web {
273 let web_url = format!("https://{host}/{}/{}/pulls", spec.owner, spec.repo);
274 open_in_browser(&web_url);
275 return Ok(());
276 }
277
278 let client = Client::new(host).context("failed to create HTTP client")?;
279
280 let needs_merged_filter = state == "merged";
284 let api_state = if needs_merged_filter { "all" } else { state };
285
286 let mut query_params = vec![
287 ("state", api_state.to_string()),
288 ("per_page", limit.min(100).to_string()),
289 ];
290
291 if let Some(b) = base {
292 query_params.push(("base", (*b).to_string()));
293 }
294 if let Some(h) = head {
295 query_params.push(("head", (*h).to_string()));
296 }
297
298 let query_string = query_params
299 .iter()
300 .map(|(k, v)| format!("{k}={v}"))
301 .collect::<Vec<_>>()
302 .join("&");
303
304 let path = format!("/repos/{}/{}/pulls?{query_string}", spec.owner, spec.repo);
305
306 let response = client.get(&path).context("failed to fetch pull requests")?;
307
308 let status = response.status();
309 if status == reqwest::StatusCode::NOT_FOUND {
310 anyhow::bail!("repository '{spec}' not found");
311 }
312 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
313 anyhow::bail!("authentication required to list pull requests for '{spec}'");
314 }
315 if !status.is_success() {
316 anyhow::bail!("failed to list pull requests for '{spec}': HTTP {status}");
317 }
318
319 let mut prs: Vec<serde_json::Value> = response
320 .json()
321 .context("failed to parse pull request response")?;
322
323 if needs_merged_filter {
325 prs.retain(|pr| pr["merged_at"].as_str().is_some());
326 }
327 if let Some(a) = author {
328 prs.retain(|pr| {
329 pr["user"]["login"]
330 .as_str()
331 .is_some_and(|login| login.eq_ignore_ascii_case(a))
332 });
333 }
334 if !labels.is_empty() {
335 prs.retain(|pr| {
336 let pr_labels: Vec<&str> = pr["labels"]
337 .as_array()
338 .map(|arr| arr.iter().filter_map(|l| l["name"].as_str()).collect())
339 .unwrap_or_default();
340 labels
341 .iter()
342 .all(|label| pr_labels.iter().any(|l| l.eq_ignore_ascii_case(label)))
343 });
344 }
345 if let Some(a) = assignee {
346 prs.retain(|pr| {
347 pr["assignees"].as_array().is_some_and(|arr| {
348 arr.iter().any(|assignee| {
349 assignee["login"]
350 .as_str()
351 .is_some_and(|login| login.eq_ignore_ascii_case(a))
352 })
353 })
354 });
355 }
356
357 if let Some(fields) = json {
359 let fields_ref: Option<&[String]> = if fields.is_empty() {
360 None
361 } else {
362 Some(&fields)
363 };
364 print_json(&prs, fields_ref);
365 return Ok(());
366 }
367
368 print_pr_table(&prs);
370 Ok(())
371}
372
373#[allow(clippy::too_many_arguments)]
384fn view(
385 number: u64,
386 repo: Option<&str>,
387 web: bool,
388 comments: bool,
389 json: Option<Vec<String>>,
390 hostname: Option<&str>,
391) -> anyhow::Result<()> {
392 let spec = match repo {
394 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
395 None => detect_remote().ok_or_else(|| {
396 anyhow::anyhow!(
397 "could not detect repository from current directory; specify OWNER/REPO"
398 )
399 })?,
400 };
401
402 let host = hostname.unwrap_or("github.com");
403
404 if web {
406 let web_url = format!("https://{host}/{}/{}/pull/{number}", spec.owner, spec.repo);
407 open_in_browser(&web_url);
408 return Ok(());
409 }
410
411 let client = Client::new(host).context("failed to create HTTP client")?;
412
413 let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
415 let response = client.get(&path).context("failed to fetch pull request")?;
416
417 let status = response.status();
418 if status == reqwest::StatusCode::NOT_FOUND {
419 anyhow::bail!("pull request #{number} not found in '{spec}'");
420 }
421 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
422 anyhow::bail!("authentication required to view pull request #{number}");
423 }
424 if !status.is_success() {
425 anyhow::bail!("failed to view pull request #{number}: HTTP {status}");
426 }
427
428 let pr: serde_json::Value = response
429 .json()
430 .context("failed to parse pull request response")?;
431
432 if let Some(fields) = json {
434 let fields_ref: Option<&[String]> = if fields.is_empty() {
435 None
436 } else {
437 Some(&fields)
438 };
439 print_json(&pr, fields_ref);
440 return Ok(());
441 }
442
443 let reviews_path = format!("/repos/{}/{}/pulls/{number}/reviews", spec.owner, spec.repo);
445 let reviews: Vec<serde_json::Value> = client
446 .get(&reviews_path)
447 .ok()
448 .and_then(|r| r.json().ok())
449 .unwrap_or_default();
450
451 let head_sha = pr["head"]["sha"].as_str().unwrap_or("");
453 let ci_status = if head_sha.is_empty() {
454 None
455 } else {
456 let status_path = format!(
457 "/repos/{}/{}/commits/{head_sha}/status",
458 spec.owner, spec.repo
459 );
460 client.get(&status_path).ok().and_then(|r| r.json().ok())
461 };
462
463 let comments_data = if comments {
465 let comments_path = format!(
466 "/repos/{}/{}/issues/{number}/comments",
467 spec.owner, spec.repo
468 );
469 client
470 .get(&comments_path)
471 .ok()
472 .and_then(|r| r.json().ok())
473 .unwrap_or_default()
474 } else {
475 Vec::<serde_json::Value>::new()
476 };
477
478 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments_data);
480 Ok(())
481}
482
483#[allow(clippy::too_many_arguments)]
495fn create(
496 repo: Option<&str>,
497 title: Option<&str>,
498 body: Option<&str>,
499 base: Option<&str>,
500 head: Option<&str>,
501 draft: bool,
502 labels: &[String],
503 assignees: &[String],
504 _milestone: Option<&str>,
505 _project: Option<u32>,
506 web: bool,
507 hostname: Option<&str>,
508) -> anyhow::Result<()> {
509 let spec = match repo {
511 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
512 None => detect_remote().ok_or_else(|| {
513 anyhow::anyhow!(
514 "could not detect repository from current directory; specify OWNER/REPO with --repo"
515 )
516 })?,
517 };
518
519 let host = hostname.unwrap_or("github.com");
520 let client = Client::new(host).context("failed to create HTTP client")?;
521
522 let head_branch = if let Some(b) = head {
524 b.to_string()
525 } else {
526 let repo =
527 gix::discover(std::env::current_dir().context("failed to get current directory")?)
528 .context("failed to discover git repository")?;
529 let head_ref = repo.head().context("failed to get HEAD")?;
530 head_ref
531 .name()
532 .shorten()
533 .to_str()
534 .context("branch name is not valid UTF-8")?
535 .to_string()
536 };
537
538 let base_branch = if let Some(b) = base {
540 b.to_string()
541 } else {
542 let path = format!("/repos/{}/{}", spec.owner, spec.repo);
543 let response = client
544 .get(&path)
545 .context("failed to fetch repository data")?;
546 let status = response.status();
547 if !status.is_success() {
548 anyhow::bail!("failed to fetch repository '{spec}': HTTP {status}");
549 }
550 let repo_data: serde_json::Value = response
551 .json()
552 .context("failed to parse repository response")?;
553 repo_data["default_branch"]
554 .as_str()
555 .ok_or_else(|| anyhow::anyhow!("could not determine default branch"))?
556 .to_string()
557 };
558
559 let mut body_map = serde_json::Map::new();
561 body_map.insert(
562 "title".to_string(),
563 serde_json::Value::String(
564 title
565 .ok_or_else(|| anyhow::anyhow!("PR title is required; use --title"))?
566 .to_string(),
567 ),
568 );
569 body_map.insert("head".to_string(), serde_json::Value::String(head_branch));
570 body_map.insert("base".to_string(), serde_json::Value::String(base_branch));
571 if let Some(b) = body {
572 body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
573 }
574 if draft {
575 body_map.insert("draft".to_string(), serde_json::Value::Bool(true));
576 }
577
578 let body_value = serde_json::Value::Object(body_map);
579
580 let path = format!("/repos/{}/{}/pulls", spec.owner, spec.repo);
582 let response = client
583 .post(&path, &body_value)
584 .context("failed to create pull request")?;
585
586 let status = response.status();
587 if status == reqwest::StatusCode::NOT_FOUND {
588 anyhow::bail!("repository '{spec}' not found");
589 }
590 if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
591 let err_body: serde_json::Value = response.json().unwrap_or_default();
592 let msg = err_body["message"].as_str().unwrap_or("validation failed");
593 anyhow::bail!("failed to create pull request: {msg}");
594 }
595 if !status.is_success() {
596 anyhow::bail!("failed to create pull request: HTTP {status}");
597 }
598
599 let pr: serde_json::Value = response
600 .json()
601 .context("failed to parse pull request response")?;
602
603 let pr_number = pr["number"].as_u64().unwrap_or(0);
604 let pr_url = pr["html_url"].as_str().unwrap_or("");
605
606 if web && !pr_url.is_empty() {
608 open_in_browser(pr_url);
609 }
610
611 println!(
613 "https://github.com/{}/{}/pull/{pr_number}",
614 spec.owner, spec.repo
615 );
616
617 if !labels.is_empty() {
619 let labels_path = format!(
620 "/repos/{}/{}/issues/{pr_number}/labels",
621 spec.owner, spec.repo
622 );
623 let labels_body = serde_json::json!({"labels": labels});
624 if let Err(e) = client.post(&labels_path, &labels_body) {
625 eprintln!("Warning: failed to add labels: {e}");
626 }
627 }
628
629 if !assignees.is_empty() {
631 let assignees_path = format!(
632 "/repos/{}/{}/issues/{pr_number}/assignees",
633 spec.owner, spec.repo
634 );
635 let assignees_body = serde_json::json!({"assignees": assignees});
636 if let Err(e) = client.post(&assignees_path, &assignees_body) {
637 eprintln!("Warning: failed to add assignees: {e}");
638 }
639 }
640
641 Ok(())
642}
643
644fn close(
653 number: u64,
654 repo: Option<&str>,
655 comment: Option<&str>,
656 hostname: Option<&str>,
657) -> anyhow::Result<()> {
658 let spec = match repo {
660 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
661 None => detect_remote().ok_or_else(|| {
662 anyhow::anyhow!(
663 "could not detect repository from current directory; specify OWNER/REPO with --repo"
664 )
665 })?,
666 };
667
668 let host = hostname.unwrap_or("github.com");
669 let client = Client::new(host).context("failed to create HTTP client")?;
670
671 if let Some(body) = comment {
673 let comment_path = format!(
674 "/repos/{}/{}/issues/{number}/comments",
675 spec.owner, spec.repo
676 );
677 let comment_body = serde_json::json!({"body": body});
678 client
679 .post(&comment_path, &comment_body)
680 .context("failed to add closing comment")?;
681 }
682
683 let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
685 let body = serde_json::json!({"state": "closed"});
686 let response = client
687 .request(
688 "PATCH",
689 &path,
690 &[],
691 Some(serde_json::to_vec(&body).unwrap_or_default()),
692 )
693 .context("failed to close pull request")?;
694
695 let status = response.status();
696 if status == reqwest::StatusCode::NOT_FOUND {
697 anyhow::bail!("pull request #{number} not found in '{spec}'");
698 }
699 if !status.is_success() {
700 anyhow::bail!("failed to close pull request #{number}: HTTP {status}");
701 }
702
703 println!("Closed pull request #{number} in {spec}");
704 Ok(())
705}
706
707fn reopen(
716 number: u64,
717 repo: Option<&str>,
718 comment: Option<&str>,
719 hostname: Option<&str>,
720) -> anyhow::Result<()> {
721 let spec = match repo {
723 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
724 None => detect_remote().ok_or_else(|| {
725 anyhow::anyhow!(
726 "could not detect repository from current directory; specify OWNER/REPO with --repo"
727 )
728 })?,
729 };
730
731 let host = hostname.unwrap_or("github.com");
732 let client = Client::new(host).context("failed to create HTTP client")?;
733
734 if let Some(body) = comment {
736 let comment_path = format!(
737 "/repos/{}/{}/issues/{number}/comments",
738 spec.owner, spec.repo
739 );
740 let comment_body = serde_json::json!({"body": body});
741 client
742 .post(&comment_path, &comment_body)
743 .context("failed to add comment")?;
744 }
745
746 let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
748 let body = serde_json::json!({"state": "open"});
749 let response = client
750 .request(
751 "PATCH",
752 &path,
753 &[],
754 Some(serde_json::to_vec(&body).unwrap_or_default()),
755 )
756 .context("failed to reopen pull request")?;
757
758 let status = response.status();
759 if status == reqwest::StatusCode::NOT_FOUND {
760 anyhow::bail!("pull request #{number} not found in '{spec}'");
761 }
762 if !status.is_success() {
763 anyhow::bail!("failed to reopen pull request #{number}: HTTP {status}");
764 }
765
766 println!("Reopened pull request #{number} in {spec}");
767 Ok(())
768}
769
770fn pr_comment(
781 number: u64,
782 repo: Option<&str>,
783 body: Option<&str>,
784 body_file: Option<&str>,
785 web: bool,
786 hostname: Option<&str>,
787) -> anyhow::Result<()> {
788 let spec = match repo {
789 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
790 None => detect_remote().ok_or_else(|| {
791 anyhow::anyhow!(
792 "could not detect repository from current directory; specify OWNER/REPO with --repo"
793 )
794 })?,
795 };
796
797 let host = hostname.unwrap_or("github.com");
798
799 if web {
801 let web_url = format!("https://{host}/{}/{}/pull/{number}", spec.owner, spec.repo);
802 open_in_browser(&web_url);
803 return Ok(());
804 }
805
806 let comment_body = match (body, body_file) {
808 (Some(b), None) => b.to_string(),
809 (None, Some(f)) => {
810 if f == "@-" {
811 let mut buf = String::new();
812 std::io::stdin()
813 .read_line(&mut buf)
814 .context("failed to read from stdin")?;
815 buf
816 } else {
817 std::fs::read_to_string(f)
818 .with_context(|| format!("failed to read body file '{f}'"))?
819 }
820 }
821 (None, None) => anyhow::bail!("either --body or --body-file is required"),
822 (Some(_), Some(_)) => unreachable!(), };
824
825 let client = Client::new(host).context("failed to create HTTP client")?;
826
827 let path = format!(
828 "/repos/{}/{}/issues/{number}/comments",
829 spec.owner, spec.repo
830 );
831 let request_body = serde_json::json!({"body": comment_body});
832 let response = client
833 .request("POST", &path, &[], Some(serde_json::to_vec(&request_body)?))
834 .context("failed to post comment")?;
835
836 let status = response.status();
837 if status == reqwest::StatusCode::NOT_FOUND {
838 anyhow::bail!("pull request #{number} not found in '{spec}'");
839 }
840 if !status.is_success() {
841 anyhow::bail!("failed to comment on pull request #{number}: HTTP {status}");
842 }
843
844 let comment: serde_json::Value = response
845 .json()
846 .context("failed to parse comment response")?;
847
848 let comment_url = comment["html_url"].as_str().unwrap_or("");
849 println!("{comment_url}");
850
851 Ok(())
852}
853
854#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
865fn pr_merge(
866 number: u64,
867 repo: Option<&str>,
868 _merge: bool,
869 squash: bool,
870 rebase: bool,
871 body: Option<&str>,
872 subject: Option<&str>,
873 delete_branch: bool,
874 _admin: bool,
875 _auto: bool,
876 hostname: Option<&str>,
877) -> anyhow::Result<()> {
878 let spec = match repo {
880 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
881 None => detect_remote().ok_or_else(|| {
882 anyhow::anyhow!(
883 "could not detect repository from current directory; specify OWNER/REPO with --repo"
884 )
885 })?,
886 };
887
888 let host = hostname.unwrap_or("github.com");
889 let client = Client::new(host).context("failed to create HTTP client")?;
890
891 let merge_method = if squash {
893 "squash"
894 } else if rebase {
895 "rebase"
896 } else {
897 "merge"
898 };
899
900 let mut body_map = serde_json::Map::new();
902 body_map.insert(
903 "merge_method".to_string(),
904 serde_json::Value::String(merge_method.to_string()),
905 );
906 if let Some(s) = subject {
907 body_map.insert(
908 "commit_title".to_string(),
909 serde_json::Value::String(s.to_string()),
910 );
911 }
912 if let Some(b) = body {
913 body_map.insert(
914 "commit_message".to_string(),
915 serde_json::Value::String(b.to_string()),
916 );
917 }
918
919 let body_value = serde_json::Value::Object(body_map);
920
921 let path = format!("/repos/{}/{}/pulls/{number}/merge", spec.owner, spec.repo);
923 let response = client
924 .request("PUT", &path, &[], Some(serde_json::to_vec(&body_value)?))
925 .context("failed to merge pull request")?;
926
927 let status = response.status();
928 if status == reqwest::StatusCode::NOT_FOUND {
929 anyhow::bail!("pull request #{number} not found in '{spec}'");
930 }
931 if status == reqwest::StatusCode::METHOD_NOT_ALLOWED {
932 anyhow::bail!("pull request #{number} cannot be merged");
933 }
934 if !status.is_success() {
935 let err_body: serde_json::Value = response.json().unwrap_or_default();
936 let msg = err_body["message"].as_str().unwrap_or("merge failed");
937 anyhow::bail!("failed to merge pull request #{number}: {msg}");
938 }
939
940 let result: serde_json::Value = response.json().context("failed to parse merge response")?;
941
942 let sha = result["sha"].as_str().unwrap_or("");
943 let merged = result["merged"].as_bool().unwrap_or(false);
944
945 if merged {
946 println!("Merged pull request #{number} in {spec} (SHA: {sha})");
947 } else {
948 let msg = result["message"].as_str().unwrap_or("unknown reason");
949 anyhow::bail!("failed to merge pull request #{number}: {msg}");
950 }
951
952 if delete_branch {
954 let pr_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
956 let pr_response = client
957 .get(&pr_path)
958 .context("failed to fetch pull request details")?;
959 if let Ok(pr_data) = pr_response.json::<serde_json::Value>() {
960 if let (Some(ref_name), Some(repo_name)) = (
961 pr_data["head"]["ref"].as_str(),
962 pr_data["head"]["repo"]["full_name"].as_str(),
963 ) {
964 if repo_name == spec.to_string() {
966 let delete_path = format!(
967 "/repos/{}/{}/git/refs/heads/{ref_name}",
968 spec.owner, spec.repo
969 );
970 if let Err(e) = client.request("DELETE", &delete_path, &[], None) {
971 eprintln!("Warning: failed to delete head branch '{ref_name}': {e}");
972 } else {
973 println!("Deleted head branch '{ref_name}'");
974 }
975 }
976 }
977 }
978 }
979
980 Ok(())
981}
982
983fn pr_checkout(
994 number: u64,
995 repo: Option<&str>,
996 branch: Option<&str>,
997 _recurse_submodules: bool,
998 hostname: Option<&str>,
999) -> anyhow::Result<()> {
1000 let spec = match repo {
1002 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1003 None => detect_remote().ok_or_else(|| {
1004 anyhow::anyhow!(
1005 "could not detect repository from current directory; specify OWNER/REPO with --repo"
1006 )
1007 })?,
1008 };
1009
1010 let host = hostname.unwrap_or("github.com");
1011 let client = Client::new(host).context("failed to create HTTP client")?;
1012
1013 let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1015 let response = client.get(&path).context("failed to fetch pull request")?;
1016
1017 let status = response.status();
1018 if status == reqwest::StatusCode::NOT_FOUND {
1019 anyhow::bail!("pull request #{number} not found in '{spec}'");
1020 }
1021 if !status.is_success() {
1022 anyhow::bail!("failed to fetch pull request #{number}: HTTP {status}");
1023 }
1024
1025 let pr: serde_json::Value = response
1026 .json()
1027 .context("failed to parse pull request response")?;
1028
1029 let head_ref = pr["head"]["ref"]
1030 .as_str()
1031 .ok_or_else(|| anyhow::anyhow!("could not determine head branch"))?;
1032 let _head_sha = pr["head"]["sha"]
1033 .as_str()
1034 .ok_or_else(|| anyhow::anyhow!("could not determine head SHA"))?;
1035 let head_repo_full_name = pr["head"]["repo"]["full_name"].as_str();
1036 let head_clone_url = pr["head"]["repo"]["clone_url"].as_str();
1037
1038 let local_branch = branch.unwrap_or(head_ref);
1040
1041 let local_repo =
1043 gix::discover(std::env::current_dir().context("failed to get current directory")?)
1044 .context("failed to discover git repository")?;
1045
1046 let remote_name = if head_repo_full_name.is_some()
1048 && head_repo_full_name != Some(spec.to_string().as_str())
1049 {
1050 let fork_owner = head_repo_full_name
1052 .and_then(|n| n.split('/').next())
1053 .unwrap_or("fork");
1054
1055 let remote_exists = local_repo.find_remote(fork_owner).is_ok();
1057
1058 if !remote_exists {
1059 if let Some(clone_url) = head_clone_url {
1060 eprintln!("Adding remote '{fork_owner}' -> {clone_url}");
1061 let config_path = local_repo.git_dir().join("config");
1062 let mut file = std::fs::OpenOptions::new()
1063 .append(true)
1064 .open(&config_path)
1065 .context("failed to open git config")?;
1066 let url_escaped = clone_url.replace('"', "\\\"");
1067 writeln!(
1068 file,
1069 "[remote \"{fork_owner}\"]\n\turl = {url_escaped}\n\tfetch = +refs/heads/*:refs/remotes/{fork_owner}/*"
1070 )
1071 .context("failed to write remote config")?;
1072 }
1073 }
1074
1075 fork_owner.to_string()
1076 } else {
1077 "origin".to_string()
1079 };
1080
1081 eprintln!("Fetching remote '{remote_name}' with branch '{head_ref}'...");
1083
1084 let workdir = local_repo
1085 .workdir()
1086 .ok_or_else(|| anyhow::anyhow!("bare repository cannot check out"))?;
1087 let workdir_str = workdir.to_str().unwrap_or(".");
1088
1089 let fetch_status = std::process::Command::new("git")
1091 .args([
1092 "-C",
1093 workdir_str,
1094 "fetch",
1095 &remote_name,
1096 &format!("+refs/heads/{head_ref}:refs/remotes/{remote_name}/{head_ref}"),
1097 ])
1098 .status()
1099 .context("failed to run git fetch")?;
1100
1101 if !fetch_status.success() {
1102 anyhow::bail!("failed to fetch from remote '{remote_name}'");
1103 }
1104
1105 eprintln!("Checking out '{local_branch}'...");
1107
1108 let checkout_status = std::process::Command::new("git")
1109 .args([
1110 "-C",
1111 workdir_str,
1112 "checkout",
1113 "-B",
1114 local_branch,
1115 &format!("refs/remotes/{remote_name}/{head_ref}"),
1116 ])
1117 .status()
1118 .context("failed to run git checkout")?;
1119
1120 if !checkout_status.success() {
1121 anyhow::bail!("failed to checkout branch '{local_branch}'");
1122 }
1123
1124 println!("Checked out PR #{number} as '{local_branch}'");
1125 Ok(())
1126}
1127
1128fn print_pr_view(
1133 pr: &serde_json::Value,
1134 reviews: &[serde_json::Value],
1135 ci_status: Option<&serde_json::Value>,
1136 comments: &[serde_json::Value],
1137) {
1138 let title = pr["title"].as_str().unwrap_or("(no title)");
1140 println!("{title}");
1141 let separator_len = title.len().min(80);
1142 println!("{}", "─".repeat(separator_len));
1143 println!();
1144
1145 let state = pr["state"].as_str().unwrap_or("unknown");
1147 let display_state = if pr["merged_at"].as_str().is_some() {
1148 "merged"
1149 } else {
1150 state
1151 };
1152 let author = pr["user"]["login"].as_str().unwrap_or("unknown");
1153 let created = pr["created_at"]
1154 .as_str()
1155 .map_or_else(|| "—".to_string(), format_date);
1156 let updated = pr["updated_at"]
1157 .as_str()
1158 .map_or_else(|| "—".to_string(), format_date);
1159 let base_branch = pr["base"]["ref"].as_str().unwrap_or("?");
1160 let head_branch = pr["head"]["ref"].as_str().unwrap_or("?");
1161
1162 let labels_str = pr["labels"]
1163 .as_array()
1164 .map(|arr| {
1165 arr.iter()
1166 .filter_map(|l| l["name"].as_str())
1167 .collect::<Vec<_>>()
1168 .join(", ")
1169 })
1170 .unwrap_or_default();
1171
1172 println!("State: {display_state}");
1173 println!("Author: {author}");
1174 println!("Created: {created}");
1175 println!("Updated: {updated}");
1176 println!("Branches: {base_branch} ← {head_branch}");
1177 if !labels_str.is_empty() {
1178 println!("Labels: {labels_str}");
1179 }
1180 println!();
1181
1182 let body = pr["body"].as_str().unwrap_or("");
1184 if !body.is_empty() {
1185 println!("{body}");
1186 println!();
1187 }
1188
1189 print_review_status(reviews);
1191
1192 print_merge_status(pr);
1194
1195 print_ci_status(ci_status);
1197
1198 if !comments.is_empty() {
1200 println!("── Comments ──");
1201 println!();
1202 for comment in comments {
1203 let comment_author = comment["user"]["login"].as_str().unwrap_or("unknown");
1204 let comment_date = comment["created_at"]
1205 .as_str()
1206 .map_or_else(|| "—".to_string(), format_date);
1207 let comment_body = comment["body"].as_str().unwrap_or("");
1208 println!("{comment_author} commented on {comment_date}");
1209 println!();
1210 println!("{comment_body}");
1211 println!();
1212 }
1213 }
1214}
1215
1216fn print_review_status(reviews: &[serde_json::Value]) {
1218 let mut latest_state: BTreeMap<&str, &str> = BTreeMap::new();
1221 for review in reviews {
1222 let user = review["user"]["login"].as_str();
1223 let state_val = review["state"].as_str();
1224 if let (Some(u), Some(s)) = (user, state_val) {
1225 if s != "COMMENTED" && s != "DISMISSED" {
1226 latest_state.insert(u, s);
1228 } else if s == "COMMENTED" && !latest_state.contains_key(u) {
1229 latest_state.insert(u, s);
1231 }
1232 }
1233 }
1234
1235 if latest_state.is_empty() {
1236 return;
1237 }
1238
1239 println!("── Review Status ──");
1240
1241 let mut approved: Vec<&str> = Vec::new();
1242 let mut changes_requested: Vec<&str> = Vec::new();
1243 let mut commented: Vec<&str> = Vec::new();
1244
1245 for (user, state_val) in &latest_state {
1246 match *state_val {
1247 "APPROVED" => approved.push(user),
1248 "CHANGES_REQUESTED" => changes_requested.push(user),
1249 _ => commented.push(user),
1250 }
1251 }
1252
1253 if !approved.is_empty() {
1254 println!("Approved by: {}", approved.join(", "));
1255 }
1256 if !changes_requested.is_empty() {
1257 println!("Changes requested by: {}", changes_requested.join(", "));
1258 }
1259 if !commented.is_empty() {
1260 println!("Commented by: {}", commented.join(", "));
1261 }
1262
1263 println!();
1264}
1265
1266fn print_merge_status(pr: &serde_json::Value) {
1268 println!("── Merge Status ──");
1269
1270 let mergeable = pr["mergeable"].as_bool();
1271 let mergeable_status = match mergeable {
1272 Some(true) => "yes",
1273 Some(false) => "no (conflicts)",
1274 None => "unknown (checking)",
1275 };
1276 println!("Mergeable: {mergeable_status}");
1277
1278 if let Some(merged_at) = pr["merged_at"].as_str() {
1279 let merged_date = format_date(merged_at);
1280 let merged_by = pr["merged_by"]["login"].as_str().unwrap_or("unknown");
1281 println!("Merged: yes ({merged_date}) by {merged_by}");
1282 } else {
1283 println!("Merged: no");
1284 }
1285
1286 println!();
1287}
1288
1289fn print_ci_status(ci_status: Option<&serde_json::Value>) {
1291 let statuses = ci_status
1292 .and_then(|s| s["statuses"].as_array())
1293 .cloned()
1294 .unwrap_or_default();
1295
1296 if statuses.is_empty() {
1297 return;
1298 }
1299
1300 println!("── CI Checks ──");
1301
1302 for check in &statuses {
1303 let name = check["context"].as_str().unwrap_or("?");
1304 let state_val = check["state"].as_str().unwrap_or("unknown");
1305 let (icon, display_state) = match state_val {
1306 "success" => ("✓", "success"),
1307 "failure" => ("✗", "failure"),
1308 "pending" => ("○", "pending"),
1309 _ => ("○", state_val),
1310 };
1311 println!(" {icon} {name} ({display_state})");
1312 }
1313
1314 println!();
1315}
1316
1317fn print_pr_table(prs: &[serde_json::Value]) {
1321 if prs.is_empty() {
1322 println!("No pull requests found.");
1323 return;
1324 }
1325
1326 let num_width = 8;
1328 let title_width = 50;
1329 let author_width = 14;
1330 let branch_width = 14;
1331 let labels_width = 14;
1332 let state_width = 8;
1333
1334 println!(
1336 "{:>num_width$} {:<title_width$} {:<author_width$} {:<branch_width$} {:<labels_width$} {:<state_width$}",
1337 "NUMBER", "TITLE", "AUTHOR", "HEAD BRANCH", "LABELS", "STATE",
1338 );
1339
1340 for pr in prs {
1341 let number = pr["number"]
1342 .as_u64()
1343 .map_or_else(|| "—".to_string(), |n| n.to_string());
1344 let title = pr["title"].as_str().unwrap_or("—");
1345 let author = pr["user"]["login"].as_str().unwrap_or("—");
1346 let head_branch = pr["head"]["ref"].as_str().unwrap_or("—");
1347 let state = pr["state"].as_str().unwrap_or("—");
1348
1349 let display_state = if pr["merged_at"].as_str().is_some() {
1351 "merged"
1352 } else {
1353 state
1354 };
1355
1356 let labels_str = pr["labels"]
1357 .as_array()
1358 .map(|arr| {
1359 arr.iter()
1360 .filter_map(|l| l["name"].as_str())
1361 .collect::<Vec<_>>()
1362 .join(", ")
1363 })
1364 .unwrap_or_default();
1365 let labels_display = if labels_str.is_empty() {
1366 "—".to_string()
1367 } else {
1368 labels_str
1369 };
1370
1371 let title_truncated = crate::cmd::util::truncate(title, title_width);
1372 let author_truncated = crate::cmd::util::truncate(author, author_width);
1373 let branch_truncated = crate::cmd::util::truncate(head_branch, branch_width);
1374 let labels_truncated = crate::cmd::util::truncate(&labels_display, labels_width);
1375
1376 println!(
1377 "{number:>num_width$} {title_truncated:<title_width$} {author_truncated:<author_width$} {branch_truncated:<branch_width$} {labels_truncated:<labels_width$} {display_state:<state_width$}",
1378 );
1379 }
1380}
1381
1382fn open_in_browser(url: &str) {
1384 #[cfg(target_os = "linux")]
1385 {
1386 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1387 }
1388 #[cfg(target_os = "macos")]
1389 {
1390 let _ = std::process::Command::new("open").arg(url).spawn();
1391 }
1392 #[cfg(target_os = "windows")]
1393 {
1394 let _ = std::process::Command::new("cmd")
1395 .args(["/c", "start", url])
1396 .spawn();
1397 }
1398 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1399 {
1400 println!("Open {url} in your browser");
1401 }
1402}
1403
1404fn diff(
1413 number: u64,
1414 repo: Option<&str>,
1415 _color: &str,
1416 name_only: bool,
1417 hostname: Option<&str>,
1418) -> anyhow::Result<()> {
1419 let spec = match repo {
1420 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1421 None => detect_remote().ok_or_else(|| {
1422 anyhow::anyhow!(
1423 "could not detect repository from current directory; specify OWNER/REPO"
1424 )
1425 })?,
1426 };
1427
1428 let host = hostname.unwrap_or("github.com");
1429 let client = Client::new(host).context("failed to create HTTP client")?;
1430
1431 if name_only {
1432 let files_path = format!(
1434 "/repos/{}/{}/pulls/{number}/files?per_page=100",
1435 spec.owner, spec.repo
1436 );
1437 let resp = client
1438 .get(&files_path)
1439 .context("failed to fetch PR files")?;
1440 let status = resp.status();
1441 if status == reqwest::StatusCode::NOT_FOUND {
1442 anyhow::bail!("pull request #{number} not found in '{spec}'");
1443 }
1444 if !status.is_success() {
1445 anyhow::bail!("failed to fetch PR files: HTTP {status}");
1446 }
1447 let files: Vec<serde_json::Value> =
1448 resp.json().context("failed to parse files response")?;
1449 for file in &files {
1450 let filename = file["filename"].as_str().unwrap_or("—");
1451 let status_str = file["status"].as_str().unwrap_or("");
1452 let additions = file["additions"].as_u64().unwrap_or(0);
1453 let deletions = file["deletions"].as_u64().unwrap_or(0);
1454 println!("{status_str:8} +{additions:4} -{deletions:4} {filename}");
1455 }
1456 } else {
1457 let diff_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1459 let resp = client
1460 .request(
1461 "GET",
1462 &diff_path,
1463 &["Accept: application/vnd.github.v3.diff".to_string()],
1464 None,
1465 )
1466 .context("failed to fetch PR diff")?;
1467 let status = resp.status();
1468 if status == reqwest::StatusCode::NOT_FOUND {
1469 anyhow::bail!("pull request #{number} not found in '{spec}'");
1470 }
1471 if !status.is_success() {
1472 anyhow::bail!("failed to fetch PR diff: HTTP {status}");
1473 }
1474 let diff_text = resp.text().context("failed to read diff response")?;
1475 println!("{diff_text}");
1476 }
1477
1478 Ok(())
1479}
1480
1481#[allow(clippy::too_many_arguments)]
1490fn pr_edit(
1491 number: u64,
1492 repo: Option<&str>,
1493 title: Option<&str>,
1494 body: Option<&str>,
1495 base: Option<&str>,
1496 add_label: &[String],
1497 remove_label: &[String],
1498 add_assignee: &[String],
1499 remove_assignee: &[String],
1500 milestone: Option<&str>,
1501 hostname: Option<&str>,
1502) -> anyhow::Result<()> {
1503 let spec = match repo {
1504 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1505 None => detect_remote().ok_or_else(|| {
1506 anyhow::anyhow!(
1507 "could not detect repository from current directory; specify OWNER/REPO"
1508 )
1509 })?,
1510 };
1511
1512 let host = hostname.unwrap_or("github.com");
1513 let client = Client::new(host).context("failed to create HTTP client")?;
1514
1515 let mut body_map = serde_json::Map::new();
1516 if let Some(t) = title {
1517 body_map.insert(
1518 "title".to_string(),
1519 serde_json::Value::String(t.to_string()),
1520 );
1521 }
1522 if let Some(b) = body {
1523 body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
1524 }
1525 if let Some(b) = base {
1526 body_map.insert("base".to_string(), serde_json::Value::String(b.to_string()));
1527 }
1528 if let Some(m) = milestone {
1529 if let Ok(id) = m.parse::<u64>() {
1530 body_map.insert(
1531 "milestone".to_string(),
1532 serde_json::Value::Number(serde_json::Number::from(id)),
1533 );
1534 } else {
1535 body_map.insert(
1536 "milestone".to_string(),
1537 serde_json::Value::String(m.to_string()),
1538 );
1539 }
1540 }
1541
1542 if !add_label.is_empty() || !remove_label.is_empty() {
1544 let get_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1545 let current: serde_json::Value = client
1546 .get(&get_path)
1547 .context("failed to fetch current PR")?
1548 .json()
1549 .context("failed to parse PR response")?;
1550 let current_labels: Vec<String> = current["labels"]
1551 .as_array()
1552 .map(|arr| {
1553 arr.iter()
1554 .filter_map(|l| l["name"].as_str().map(String::from))
1555 .collect()
1556 })
1557 .unwrap_or_default();
1558 let mut new_labels = current_labels;
1559 for label in add_label {
1560 if !new_labels.contains(label) {
1561 new_labels.push(label.clone());
1562 }
1563 }
1564 new_labels.retain(|l| !remove_label.contains(l));
1565 body_map.insert(
1566 "labels".to_string(),
1567 serde_json::Value::Array(
1568 new_labels
1569 .iter()
1570 .map(|l| serde_json::Value::String(l.clone()))
1571 .collect(),
1572 ),
1573 );
1574 }
1575
1576 if !add_assignee.is_empty() || !remove_assignee.is_empty() {
1578 let get_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1579 let current: serde_json::Value = client
1580 .get(&get_path)
1581 .context("failed to fetch current PR")?
1582 .json()
1583 .context("failed to parse PR response")?;
1584 let current_assignees: Vec<String> = current["assignees"]
1585 .as_array()
1586 .map(|arr| {
1587 arr.iter()
1588 .filter_map(|a| a["login"].as_str().map(String::from))
1589 .collect()
1590 })
1591 .unwrap_or_default();
1592 let mut new_assignees = current_assignees;
1593 for a in add_assignee {
1594 if !new_assignees.contains(a) {
1595 new_assignees.push(a.clone());
1596 }
1597 }
1598 new_assignees.retain(|a| !remove_assignee.contains(a));
1599 body_map.insert(
1600 "assignees".to_string(),
1601 serde_json::Value::Array(
1602 new_assignees
1603 .iter()
1604 .map(|a| serde_json::Value::String(a.clone()))
1605 .collect(),
1606 ),
1607 );
1608 }
1609
1610 if body_map.is_empty() {
1611 anyhow::bail!("no changes specified");
1612 }
1613
1614 let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1615 let body_value = serde_json::Value::Object(body_map);
1616 let response = client
1617 .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
1618 .context("failed to edit PR")?;
1619
1620 let status = response.status();
1621 if status == reqwest::StatusCode::NOT_FOUND {
1622 anyhow::bail!("pull request #{number} not found in '{spec}'");
1623 }
1624 if !status.is_success() {
1625 let err_body: serde_json::Value = response.json().unwrap_or_default();
1626 let msg = err_body["message"].as_str().unwrap_or("edit failed");
1627 anyhow::bail!("failed to edit PR #{number}: {msg}");
1628 }
1629
1630 let pr: serde_json::Value = response.json().context("failed to parse response")?;
1631 let pr_number = pr["number"].as_u64().unwrap_or(number);
1632 let pr_title = pr["title"].as_str().unwrap_or("—");
1633 let pr_base = pr["base"]["ref"].as_str().unwrap_or("—");
1634 let pr_labels: Vec<&str> = pr["labels"]
1635 .as_array()
1636 .map(|arr| arr.iter().filter_map(|l| l["name"].as_str()).collect())
1637 .unwrap_or_default();
1638 let pr_assignees: Vec<&str> = pr["assignees"]
1639 .as_array()
1640 .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
1641 .unwrap_or_default();
1642 let pr_milestone = pr["milestone"]["title"].as_str().unwrap_or("—");
1643
1644 println!("✓ Updated PR #{pr_number}: {pr_title}");
1645 println!(" Base: {pr_base}");
1646 println!(" Labels: {}", pr_labels.join(", "));
1647 println!(" Assignees: {}", pr_assignees.join(", "));
1648 println!(" Milestone: {pr_milestone}");
1649 Ok(())
1650}
1651
1652fn review(
1661 number: u64,
1662 repo: Option<&str>,
1663 approve: bool,
1664 request_changes: bool,
1665 _comment: bool,
1666 body: Option<&str>,
1667 hostname: Option<&str>,
1668) -> anyhow::Result<()> {
1669 let spec = match repo {
1670 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1671 None => detect_remote().ok_or_else(|| {
1672 anyhow::anyhow!(
1673 "could not detect repository from current directory; specify OWNER/REPO"
1674 )
1675 })?,
1676 };
1677
1678 let host = hostname.unwrap_or("github.com");
1679 let client = Client::new(host).context("failed to create HTTP client")?;
1680
1681 let event = if approve {
1682 "APPROVE"
1683 } else if request_changes {
1684 "REQUEST_CHANGES"
1685 } else {
1686 "COMMENT"
1687 };
1688
1689 let mut body_map = serde_json::Map::new();
1690 body_map.insert(
1691 "event".to_string(),
1692 serde_json::Value::String(event.to_string()),
1693 );
1694 if let Some(b) = body {
1695 body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
1696 }
1697
1698 let path = format!("/repos/{}/{}/pulls/{number}/reviews", spec.owner, spec.repo);
1699 let body_value = serde_json::Value::Object(body_map);
1700 let response = client
1701 .post(&path, &body_value)
1702 .context("failed to submit review")?;
1703
1704 let status = response.status();
1705 if status == reqwest::StatusCode::NOT_FOUND {
1706 anyhow::bail!("pull request #{number} not found in '{spec}'");
1707 }
1708 if !status.is_success() {
1709 let err_body: serde_json::Value = response.json().unwrap_or_default();
1710 let msg = err_body["message"].as_str().unwrap_or("review failed");
1711 anyhow::bail!("failed to submit review for PR #{number}: {msg}");
1712 }
1713
1714 let review: serde_json::Value = response.json().context("failed to parse response")?;
1715 let state = review["state"].as_str().unwrap_or(event);
1716 println!("✓ Submitted {state} review on PR #{number}");
1717 Ok(())
1718}
1719
1720#[allow(clippy::needless_pass_by_value)]
1729fn checks(
1730 number: u64,
1731 repo: Option<&str>,
1732 watch: bool,
1733 json: Option<Vec<String>>,
1734 hostname: Option<&str>,
1735) -> anyhow::Result<()> {
1736 let spec = match repo {
1737 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1738 None => detect_remote().ok_or_else(|| {
1739 anyhow::anyhow!(
1740 "could not detect repository from current directory; specify OWNER/REPO"
1741 )
1742 })?,
1743 };
1744
1745 let host = hostname.unwrap_or("github.com");
1746 let client = Client::new(host).context("failed to create HTTP client")?;
1747
1748 let pr_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1750 let pr_resp = client.get(&pr_path).context("failed to fetch PR")?;
1751 let pr_status = pr_resp.status();
1752 if pr_status == reqwest::StatusCode::NOT_FOUND {
1753 anyhow::bail!("pull request #{number} not found in '{spec}'");
1754 }
1755 if !pr_status.is_success() {
1756 anyhow::bail!("failed to fetch PR #{number}: HTTP {pr_status}");
1757 }
1758 let pr: serde_json::Value = pr_resp.json().context("failed to parse PR response")?;
1759 let head_sha = pr["head"]["sha"].as_str().context("PR missing head SHA")?;
1760
1761 loop {
1762 let checks_path = format!(
1763 "/repos/{}/{}/commits/{head_sha}/check-runs?per_page=100",
1764 spec.owner, spec.repo
1765 );
1766 let resp = client
1767 .get(&checks_path)
1768 .context("failed to fetch check runs")?;
1769 let status = resp.status();
1770 if !status.is_success() {
1771 anyhow::bail!("failed to fetch checks: HTTP {status}");
1772 }
1773 let data: serde_json::Value = resp.json().context("failed to parse checks response")?;
1774 let check_runs = data["check_runs"]
1775 .as_array()
1776 .map_or(&[] as &[serde_json::Value], |a| a);
1777
1778 if let Some(ref fields) = json {
1779 let fields_ref: Option<&[String]> = if fields.is_empty() {
1780 None
1781 } else {
1782 Some(fields)
1783 };
1784 print_json(&check_runs, fields_ref);
1785 return Ok(());
1786 }
1787
1788 if check_runs.is_empty() {
1789 println!("No checks found for PR #{number}.");
1790 return Ok(());
1791 }
1792
1793 let mut passed = 0u32;
1794 let mut failed = 0u32;
1795 let mut pending = 0u32;
1796
1797 for check in check_runs {
1798 let name = check["name"].as_str().unwrap_or("—");
1799 let check_status = check["status"].as_str().unwrap_or("unknown");
1800 let conclusion = check["conclusion"].as_str().unwrap_or("pending");
1801 let details_url = check["html_url"].as_str().unwrap_or("—");
1802
1803 let icon = match (check_status, conclusion) {
1804 ("completed", "success" | "neutral") => {
1805 passed += 1;
1806 "✓"
1807 }
1808 ("completed", "failure" | "timed_out" | "cancelled" | "action_required") => {
1809 failed += 1;
1810 "✗"
1811 }
1812 _ => {
1813 pending += 1;
1814 "○"
1815 }
1816 };
1817
1818 println!("{icon} {name:<40} {check_status:<12} {conclusion:<16} {details_url}");
1819 }
1820
1821 println!();
1822 println!("{passed} passed, {failed} failed, {pending} pending");
1823
1824 if !watch || pending == 0 {
1825 if failed > 0 {
1826 std::process::exit(1);
1827 }
1828 return Ok(());
1829 }
1830
1831 std::thread::sleep(std::time::Duration::from_secs(5));
1832 }
1833}
1834
1835fn ready(number: u64, repo: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
1843 let host = hostname.unwrap_or("github.com");
1844 let client = Client::new(host).context("failed to create HTTP client")?;
1845
1846 let spec = if let Some(r) = repo {
1847 parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
1848 } else {
1849 detect_remote().context("could not detect repository from git remote")?
1850 };
1851
1852 let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1853
1854 let response = client.get(&path).context("failed to fetch PR")?;
1856 let status = response.status();
1857 if !status.is_success() {
1858 anyhow::bail!("failed to fetch PR #{number}: HTTP {status}");
1859 }
1860
1861 let pr: serde_json::Value = response.json().context("failed to parse PR response")?;
1862 let is_draft = pr["draft"].as_bool().unwrap_or(false);
1863
1864 if !is_draft {
1865 println!("PR #{number} is already ready for review.");
1866 return Ok(());
1867 }
1868
1869 let body = serde_json::json!({"draft": false});
1871 let body_bytes = serde_json::to_vec(&body).context("failed to serialize body")?;
1872 let update_response = client
1873 .request("PATCH", &path, &[], Some(body_bytes))
1874 .context("failed to update PR")?;
1875
1876 let update_status = update_response.status();
1877 if !update_status.is_success() {
1878 let err_body: serde_json::Value = update_response.json().unwrap_or_default();
1879 let msg = err_body["message"].as_str().unwrap_or("update failed");
1880 anyhow::bail!("failed to mark PR #{number} as ready: {msg}");
1881 }
1882
1883 println!("PR #{number} is now ready for review.");
1884 Ok(())
1885}
1886
1887#[cfg(test)]
1888#[allow(clippy::expect_used)]
1889mod tests {
1890 use super::*;
1891 use serde_json::json;
1892
1893 #[test]
1894 fn print_pr_table_basic() {
1895 let prs = vec![json!({
1896 "number": 42,
1897 "title": "Fix authentication bug in login flow",
1898 "state": "open",
1899 "merged_at": null,
1900 "user": { "login": "octocat" },
1901 "head": { "ref": "fix-auth" },
1902 "labels": [
1903 { "name": "bug" },
1904 { "name": "security" }
1905 ]
1906 })];
1907 print_pr_table(&prs);
1909 }
1910
1911 #[test]
1912 fn print_pr_table_merged() {
1913 let prs = vec![json!({
1914 "number": 100,
1915 "title": "Add new feature",
1916 "state": "closed",
1917 "merged_at": "2024-01-15T10:30:00Z",
1918 "user": { "login": "dev-user" },
1919 "head": { "ref": "feature-branch" },
1920 "labels": []
1921 })];
1922 print_pr_table(&prs);
1924 }
1925
1926 #[test]
1927 fn print_pr_table_empty() {
1928 let prs: Vec<serde_json::Value> = vec![];
1929 print_pr_table(&prs);
1931 }
1932
1933 #[test]
1934 fn print_pr_table_multiple() {
1935 let prs = vec![
1936 json!({
1937 "number": 1,
1938 "title": "First PR",
1939 "state": "open",
1940 "merged_at": null,
1941 "user": { "login": "alice" },
1942 "head": { "ref": "feature-a" },
1943 "labels": [{"name": "enhancement"}]
1944 }),
1945 json!({
1946 "number": 2,
1947 "title": "Second PR with a very long title that should be truncated in the table output",
1948 "state": "open",
1949 "merged_at": null,
1950 "user": { "login": "bob" },
1951 "head": { "ref": "feature-b" },
1952 "labels": [{"name": "bug"}, {"name": "docs"}]
1953 }),
1954 ];
1955 print_pr_table(&prs);
1957 }
1958
1959 #[test]
1960 fn print_pr_table_null_fields() {
1961 let prs = vec![json!({
1962 "number": 99,
1963 "title": null,
1964 "state": null,
1965 "merged_at": null,
1966 "user": null,
1967 "head": null,
1968 "labels": null
1969 })];
1970 print_pr_table(&prs);
1972 }
1973
1974 #[test]
1975 fn open_in_browser_does_not_panic() {
1976 open_in_browser("https://github.com/octocat/hello-world/pulls");
1978 }
1979
1980 #[test]
1981 fn print_pr_view_basic() {
1982 let pr = json!({
1983 "number": 42,
1984 "title": "Fix authentication bug",
1985 "state": "open",
1986 "merged_at": null,
1987 "user": { "login": "octocat" },
1988 "created_at": "2024-01-15T10:30:00Z",
1989 "updated_at": "2024-01-16T12:00:00Z",
1990 "body": "This PR fixes the authentication bug.",
1991 "base": { "ref": "main" },
1992 "head": { "ref": "fix-auth", "sha": "abc123" },
1993 "labels": [
1994 { "name": "bug" },
1995 { "name": "security" }
1996 ],
1997 "mergeable": true,
1998 "merged_by": null
1999 });
2000 let reviews: Vec<serde_json::Value> = vec![];
2001 let ci_status: Option<serde_json::Value> = None;
2002 let comments: Vec<serde_json::Value> = vec![];
2003 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2005 }
2006
2007 #[test]
2008 fn print_pr_view_merged() {
2009 let pr = json!({
2010 "number": 100,
2011 "title": "Add new feature",
2012 "state": "closed",
2013 "merged_at": "2024-01-15T10:30:00Z",
2014 "user": { "login": "dev-user" },
2015 "created_at": "2024-01-10T08:00:00Z",
2016 "updated_at": "2024-01-15T10:30:00Z",
2017 "body": "This adds a new feature.",
2018 "base": { "ref": "main" },
2019 "head": { "ref": "feature-branch", "sha": "def456" },
2020 "labels": [],
2021 "mergeable": null,
2022 "merged_by": { "login": "admin" }
2023 });
2024 let reviews: Vec<serde_json::Value> = vec![];
2025 let ci_status: Option<serde_json::Value> = None;
2026 let comments: Vec<serde_json::Value> = vec![];
2027 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2029 }
2030
2031 #[test]
2032 fn print_pr_view_with_reviews() {
2033 let pr = json!({
2034 "number": 42,
2035 "title": "Fix bug",
2036 "state": "open",
2037 "merged_at": null,
2038 "user": { "login": "octocat" },
2039 "created_at": "2024-01-15T10:30:00Z",
2040 "updated_at": "2024-01-16T12:00:00Z",
2041 "body": "Fixes a bug.",
2042 "base": { "ref": "main" },
2043 "head": { "ref": "fix-bug", "sha": "abc123" },
2044 "labels": [],
2045 "mergeable": true,
2046 "merged_by": null
2047 });
2048 let reviews = vec![
2049 json!({
2050 "user": { "login": "reviewer1" },
2051 "state": "APPROVED"
2052 }),
2053 json!({
2054 "user": { "login": "reviewer2" },
2055 "state": "CHANGES_REQUESTED"
2056 }),
2057 json!({
2058 "user": { "login": "reviewer3" },
2059 "state": "COMMENTED"
2060 }),
2061 ];
2062 let ci_status: Option<serde_json::Value> = None;
2063 let comments: Vec<serde_json::Value> = vec![];
2064 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2066 }
2067
2068 #[test]
2069 fn print_pr_view_with_ci() {
2070 let pr = json!({
2071 "number": 42,
2072 "title": "Fix bug",
2073 "state": "open",
2074 "merged_at": null,
2075 "user": { "login": "octocat" },
2076 "created_at": "2024-01-15T10:30:00Z",
2077 "updated_at": "2024-01-16T12:00:00Z",
2078 "body": "Fixes a bug.",
2079 "base": { "ref": "main" },
2080 "head": { "ref": "fix-bug", "sha": "abc123" },
2081 "labels": [],
2082 "mergeable": true,
2083 "merged_by": null
2084 });
2085 let reviews: Vec<serde_json::Value> = vec![];
2086 let ci_status = Some(json!({
2087 "statuses": [
2088 { "context": "CI / test", "state": "success" },
2089 { "context": "CI / lint", "state": "failure" },
2090 { "context": "CI / build", "state": "pending" }
2091 ]
2092 }));
2093 let comments: Vec<serde_json::Value> = vec![];
2094 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2096 }
2097
2098 #[test]
2099 fn print_pr_view_with_comments() {
2100 let pr = json!({
2101 "number": 42,
2102 "title": "Fix bug",
2103 "state": "open",
2104 "merged_at": null,
2105 "user": { "login": "octocat" },
2106 "created_at": "2024-01-15T10:30:00Z",
2107 "updated_at": "2024-01-16T12:00:00Z",
2108 "body": "Fixes a bug.",
2109 "base": { "ref": "main" },
2110 "head": { "ref": "fix-bug", "sha": "abc123" },
2111 "labels": [],
2112 "mergeable": true,
2113 "merged_by": null
2114 });
2115 let reviews: Vec<serde_json::Value> = vec![];
2116 let ci_status: Option<serde_json::Value> = None;
2117 let comments = vec![
2118 json!({
2119 "user": { "login": "reviewer1" },
2120 "created_at": "2024-01-16T14:00:00Z",
2121 "body": "Looks good to me!"
2122 }),
2123 json!({
2124 "user": { "login": "octocat" },
2125 "created_at": "2024-01-16T15:00:00Z",
2126 "body": "Thanks for the review!"
2127 }),
2128 ];
2129 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2131 }
2132
2133 #[test]
2134 fn print_pr_view_null_fields() {
2135 let pr = json!({
2136 "number": 99,
2137 "title": null,
2138 "state": null,
2139 "merged_at": null,
2140 "user": null,
2141 "created_at": null,
2142 "updated_at": null,
2143 "body": null,
2144 "base": null,
2145 "head": null,
2146 "labels": null,
2147 "mergeable": null,
2148 "merged_by": null
2149 });
2150 let reviews: Vec<serde_json::Value> = vec![];
2151 let ci_status: Option<serde_json::Value> = None;
2152 let comments: Vec<serde_json::Value> = vec![];
2153 print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2155 }
2156
2157 #[test]
2158 fn print_review_status_empty() {
2159 let reviews: Vec<serde_json::Value> = vec![];
2160 print_review_status(&reviews);
2162 }
2163
2164 #[test]
2165 fn print_review_status_with_reviews() {
2166 let reviews = vec![
2167 json!({
2168 "user": { "login": "alice" },
2169 "state": "APPROVED"
2170 }),
2171 json!({
2172 "user": { "login": "bob" },
2173 "state": "CHANGES_REQUESTED"
2174 }),
2175 json!({
2176 "user": { "login": "carol" },
2177 "state": "COMMENTED"
2178 }),
2179 ];
2180 print_review_status(&reviews);
2182 }
2183
2184 #[test]
2185 fn print_merge_status_mergeable() {
2186 let pr = json!({
2187 "mergeable": true,
2188 "merged_at": null,
2189 "merged_by": null
2190 });
2191 print_merge_status(&pr);
2193 }
2194
2195 #[test]
2196 fn print_merge_status_conflicts() {
2197 let pr = json!({
2198 "mergeable": false,
2199 "merged_at": null,
2200 "merged_by": null
2201 });
2202 print_merge_status(&pr);
2204 }
2205
2206 #[test]
2207 fn print_merge_status_merged() {
2208 let pr = json!({
2209 "mergeable": null,
2210 "merged_at": "2024-01-15T10:30:00Z",
2211 "merged_by": { "login": "admin" }
2212 });
2213 print_merge_status(&pr);
2215 }
2216
2217 #[test]
2218 fn print_ci_status_empty() {
2219 let ci_status: Option<serde_json::Value> = None;
2220 print_ci_status(ci_status.as_ref());
2222 }
2223
2224 #[test]
2225 fn print_ci_status_with_checks() {
2226 let ci_status = Some(json!({
2227 "statuses": [
2228 { "context": "CI / test", "state": "success" },
2229 { "context": "CI / lint", "state": "failure" },
2230 { "context": "CI / build", "state": "pending" }
2231 ]
2232 }));
2233 print_ci_status(ci_status.as_ref());
2235 }
2236}