1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::Path;
95
96use processkit::ProcessRunner;
97pub use processkit::{Error, ProcessResult, Result};
100#[cfg(feature = "cancellation")]
103#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
104pub use processkit::CancellationToken;
105
106mod parse;
107pub use parse::{
108 CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, Repo, Review, WorkflowRun,
109};
110
111pub const BINARY: &str = "gh";
113
114const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
115const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
116const ISSUE_LIST_FIELDS: &str = "number,title,state";
117const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url";
118const RUN_FIELDS: &str =
119 "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
120const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
121const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
122const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease";
123
124fn reject_flag_like(what: &str, value: &str) -> Result<()> {
131 vcs_cli_support::reject_flag_like(BINARY, what, value)
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum MergeStrategy {
139 Merge,
141 Squash,
143 Rebase,
145}
146
147impl MergeStrategy {
148 fn flag(self) -> &'static str {
149 match self {
150 MergeStrategy::Merge => "--merge",
151 MergeStrategy::Squash => "--squash",
152 MergeStrategy::Rebase => "--rebase",
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub struct PrMerge {
166 pub strategy: MergeStrategy,
168 pub auto: bool,
170 pub delete_branch: bool,
172}
173
174impl PrMerge {
175 pub fn merge() -> Self {
177 Self::with(MergeStrategy::Merge)
178 }
179
180 pub fn squash() -> Self {
182 Self::with(MergeStrategy::Squash)
183 }
184
185 pub fn rebase() -> Self {
187 Self::with(MergeStrategy::Rebase)
188 }
189
190 fn with(strategy: MergeStrategy) -> Self {
191 Self {
192 strategy,
193 auto: false,
194 delete_branch: false,
195 }
196 }
197
198 pub fn auto(mut self) -> Self {
200 self.auto = true;
201 self
202 }
203
204 pub fn delete_branch(mut self) -> Self {
206 self.delete_branch = true;
207 self
208 }
209}
210
211#[derive(Debug, Clone)]
217#[non_exhaustive]
218pub struct PrCreate {
219 pub title: String,
221 pub body: String,
223 pub head: Option<String>,
225 pub base: Option<String>,
227}
228
229impl PrCreate {
230 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
233 Self {
234 title: title.into(),
235 body: body.into(),
236 head: None,
237 base: None,
238 }
239 }
240
241 pub fn head(mut self, head: impl Into<String>) -> Self {
243 self.head = Some(head.into());
244 self
245 }
246
247 pub fn base(mut self, base: impl Into<String>) -> Self {
249 self.base = Some(base.into());
250 self
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257#[non_exhaustive]
258pub enum ReviewKind {
259 Approve,
261 RequestChanges,
263 Comment,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
278#[non_exhaustive]
279pub struct ReviewAction {
280 kind: ReviewKind,
281 body: Option<String>,
282}
283
284impl ReviewAction {
285 pub fn approve() -> Self {
288 Self {
289 kind: ReviewKind::Approve,
290 body: None,
291 }
292 }
293
294 pub fn request_changes(body: impl Into<String>) -> Self {
297 Self {
298 kind: ReviewKind::RequestChanges,
299 body: Some(body.into()),
300 }
301 }
302
303 pub fn comment(body: impl Into<String>) -> Self {
305 Self {
306 kind: ReviewKind::Comment,
307 body: Some(body.into()),
308 }
309 }
310
311 pub fn with_body(mut self, body: impl Into<String>) -> Self {
314 self.body = Some(body.into());
315 self
316 }
317
318 pub fn kind(&self) -> ReviewKind {
320 self.kind
321 }
322
323 pub fn body(&self) -> Option<&str> {
325 self.body.as_deref()
326 }
327}
328
329#[cfg_attr(feature = "mock", mockall::automock)]
332#[async_trait::async_trait]
333pub trait GitHubApi: Send + Sync {
334 async fn run(&self, args: &[String]) -> Result<String>;
336 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
339 async fn version(&self) -> Result<String>;
341 async fn auth_status(&self) -> Result<bool>;
345 async fn repo_view(&self, dir: &Path) -> Result<Repo>;
347 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
350 async fn pr_list_for_branch(
355 &self,
356 dir: &Path,
357 head: &str,
358 base: &str,
359 ) -> Result<Vec<PullRequest>>;
360 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
362 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
365 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
369 async fn api(&self, endpoint: &str) -> Result<String>;
371
372 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
377 async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()>;
379 async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()>;
382 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
389 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
393 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
396 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
399
400 async fn run_list(
406 &self,
407 dir: &Path,
408 limit: u64,
409 branch: Option<String>,
410 ) -> Result<Vec<WorkflowRun>>;
411 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
414 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
424
425 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
430 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
433 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
437 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
441}
442
443processkit::cli_client!(
444 pub struct GitHub => BINARY
448);
449
450#[async_trait::async_trait]
451impl<R: ProcessRunner> GitHubApi for GitHub<R> {
452 async fn run(&self, args: &[String]) -> Result<String> {
453 self.core.run(self.core.command(args)).await
454 }
455
456 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
457 self.core.output(self.core.command(args)).await
458 }
459
460 async fn version(&self) -> Result<String> {
461 self.core.run(self.core.command(["--version"])).await
462 }
463
464 async fn auth_status(&self) -> Result<bool> {
465 Ok(self
471 .core
472 .exit_code(self.core.command(["auth", "status"]))
473 .await?
474 == 0)
475 }
476
477 async fn repo_view(&self, dir: &Path) -> Result<Repo> {
478 self.core
479 .try_parse(
480 self.core
481 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
482 parse::parse_repo,
483 )
484 .await
485 }
486
487 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
488 self.core
489 .try_parse(
490 self.core
491 .command_in(dir, ["pr", "list", "--limit", "100", "--json", PR_FIELDS]),
492 parse::from_json,
493 )
494 .await
495 }
496
497 async fn pr_list_for_branch(
498 &self,
499 dir: &Path,
500 head: &str,
501 base: &str,
502 ) -> Result<Vec<PullRequest>> {
503 self.core
506 .try_parse(
507 self.core.command_in(
508 dir,
509 [
510 "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
511 "100", "--json", PR_FIELDS,
512 ],
513 ),
514 parse::from_json,
515 )
516 .await
517 }
518
519 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
520 let n = number.to_string();
521 self.core
522 .try_parse(
523 self.core
524 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
525 parse::from_json,
526 )
527 .await
528 }
529
530 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
531 self.core
532 .try_parse(
533 self.core.command_in(
534 dir,
535 [
536 "issue",
537 "list",
538 "--limit",
539 "100",
540 "--json",
541 ISSUE_LIST_FIELDS,
542 ],
543 ),
544 parse::from_json,
545 )
546 .await
547 }
548
549 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
550 let mut args = vec![
551 "pr",
552 "create",
553 "--title",
554 spec.title.as_str(),
555 "--body",
556 spec.body.as_str(),
557 ];
558 if let Some(head) = spec.head.as_deref() {
559 args.push("--head");
560 args.push(head);
561 }
562 if let Some(base) = spec.base.as_deref() {
563 args.push("--base");
564 args.push(base);
565 }
566 self.core.run(self.core.command_in(dir, args)).await
567 }
568
569 async fn api(&self, endpoint: &str) -> Result<String> {
570 reject_flag_like("endpoint", endpoint)?;
571 self.core.run(self.core.command(["api", endpoint])).await
572 }
573
574 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
575 let n = number.to_string();
576 let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
577 if merge.auto {
578 args.push("--auto");
579 }
580 if merge.delete_branch {
581 args.push("--delete-branch");
582 }
583 self.core.run_unit(self.core.command_in(dir, args)).await
584 }
585
586 async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()> {
587 let n = number.to_string();
588 self.core
589 .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
590 .await
591 }
592
593 async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()> {
594 let n = number.to_string();
595 let mut args = vec!["pr", "close", n.as_str()];
596 if delete_branch {
597 args.push("--delete-branch");
598 }
599 self.core.run_unit(self.core.command_in(dir, args)).await
600 }
601
602 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
603 let n = number.to_string();
604 let res = self
605 .core
606 .output(
607 self.core
608 .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
609 )
610 .await?;
611 match res.code() {
612 Some(0) => parse::from_json(res.stdout()),
618 Some(1 | 8) if !res.stdout().trim().is_empty() => parse::from_json(res.stdout()),
619 _ if res.stderr().contains("no checks reported") => Ok(Vec::new()),
623 _ => {
626 res.ensure_success()?;
627 Ok(Vec::new()) }
629 }
630 }
631
632 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
633 let n = number.to_string();
634 let mut args = vec!["pr", "review", n.as_str()];
635 args.push(match action.kind() {
636 ReviewKind::Approve => "--approve",
637 ReviewKind::RequestChanges => "--request-changes",
638 ReviewKind::Comment => "--comment",
639 });
640 if let Some(body) = action.body() {
641 args.push("--body");
642 args.push(body);
643 }
644 self.core.run_unit(self.core.command_in(dir, args)).await
645 }
646
647 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
648 let n = number.to_string();
651 self.core
652 .run(
653 self.core
654 .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
655 )
656 .await
657 }
658
659 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
660 let n = number.to_string();
661 self.core
662 .try_parse(
663 self.core.command_in(
664 dir,
665 ["pr", "view", n.as_str(), "--json", "reviews,comments"],
666 ),
667 parse::parse_feedback,
668 )
669 .await
670 }
671
672 async fn run_list(
673 &self,
674 dir: &Path,
675 limit: u64,
676 branch: Option<String>,
677 ) -> Result<Vec<WorkflowRun>> {
678 let limit = limit.to_string();
679 let mut args = vec!["run", "list", "--limit", limit.as_str()];
680 if let Some(branch) = branch.as_deref() {
681 args.push("--branch");
682 args.push(branch);
683 }
684 args.extend(["--json", RUN_FIELDS]);
685 self.core
686 .try_parse(self.core.command_in(dir, args), parse::from_json)
687 .await
688 }
689
690 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
691 let id = id.to_string();
692 self.core
693 .try_parse(
694 self.core
695 .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
696 parse::from_json,
697 )
698 .await
699 }
700
701 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
702 let id_str = id.to_string();
711 self.core
712 .output(self.core.command_in(dir, ["run", "watch", id_str.as_str()]))
713 .await?
714 .ensure_success()?;
715 self.run_view(dir, id).await
716 }
717
718 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
719 self.core
720 .run(
721 self.core
722 .command_in(dir, ["issue", "create", "--title", title, "--body", body]),
723 )
724 .await
725 }
726
727 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
728 let n = number.to_string();
729 self.core
730 .try_parse(
731 self.core.command_in(
732 dir,
733 ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
734 ),
735 parse::from_json,
736 )
737 .await
738 }
739
740 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
741 self.core
742 .try_parse(
743 self.core.command_in(
744 dir,
745 [
746 "release",
747 "list",
748 "--limit",
749 "100",
750 "--json",
751 RELEASE_LIST_FIELDS,
752 ],
753 ),
754 parse::from_json,
755 )
756 .await
757 }
758
759 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
760 reject_flag_like("tag", tag)?;
761 self.core
762 .try_parse(
763 self.core
764 .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
765 parse::from_json,
766 )
767 .await
768 }
769}
770
771impl<R: ProcessRunner> GitHub<R> {
772 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
777 self.core.run(self.core.command(args)).await
778 }
779
780 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
783 self.core.output(self.core.command(args)).await
784 }
785
786 pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
790 GitHubAt { gh: self, dir }
791 }
792}
793
794pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
798 gh: &'a GitHub<R>,
799 dir: &'a Path,
800}
801
802impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
806 fn clone(&self) -> Self {
807 *self
808 }
809}
810impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
811
812macro_rules! github_at_forwarders {
815 (
816 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
817 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
818 ) => {
819 impl<'a, R: ProcessRunner> GitHubAt<'a, R> {
820 $(
821 #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($bn), "`.")]
822 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
823 self.gh.$bn($($ba),*).await
824 }
825 )*
826 $(
827 #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
828 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
829 self.gh.$dn(self.dir, $($da),*).await
830 }
831 )*
832 }
833 };
834}
835
836github_at_forwarders! {
837 bare {
838 fn run(args: &[String]) -> Result<String>;
839 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
840 fn run_args(args: &[&str]) -> Result<String>;
841 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
842 fn version() -> Result<String>;
843 fn auth_status() -> Result<bool>;
844 fn api(endpoint: &str) -> Result<String>;
845 }
846 dir {
847 fn repo_view() -> Result<Repo>;
848 fn pr_list() -> Result<Vec<PullRequest>>;
849 fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
850 fn pr_view(number: u64) -> Result<PullRequest>;
851 fn issue_list() -> Result<Vec<Issue>>;
852 fn pr_create(spec: PrCreate) -> Result<String>;
853 fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
854 fn pr_ready(number: u64) -> Result<()>;
855 fn pr_close(number: u64, delete_branch: bool) -> Result<()>;
856 fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
857 fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
858 fn pr_comment(number: u64, body: &str) -> Result<String>;
859 fn pr_feedback(number: u64) -> Result<PrFeedback>;
860 fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
861 fn run_view(id: u64) -> Result<WorkflowRun>;
862 fn run_watch(id: u64) -> Result<WorkflowRun>;
863 fn issue_create(title: &str, body: &str) -> Result<String>;
864 fn issue_view(number: u64) -> Result<Issue>;
865 fn release_list() -> Result<Vec<Release>>;
866 fn release_view(tag: &str) -> Result<Release>;
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use processkit::{RecordingRunner, Reply, ScriptedRunner};
874
875 #[test]
876 fn binary_name_is_gh() {
877 assert_eq!(BINARY, "gh");
878 }
879
880 #[allow(dead_code)]
882 fn bound_view_is_copy_for_default_runner() {
883 fn assert_copy<T: Copy>() {}
884 assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
885 }
886
887 #[tokio::test]
890 async fn bound_view_matches_dir_taking_calls() {
891 let dir = Path::new("/repo");
892 let rec = RecordingRunner::replying(Reply::ok("[]"));
893 let gh = GitHub::with_runner(&rec);
894
895 gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
896 gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
897 gh.run_list(dir, 3, None).await.unwrap();
899 gh.at(dir).run_list(3, None).await.unwrap();
900
901 let calls = rec.calls();
902 assert_eq!(calls[0].args_str(), calls[1].args_str());
903 assert_eq!(calls[2].args_str(), calls[3].args_str());
904 assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
905 }
906
907 #[tokio::test]
908 async fn run_args_forwards_str_slices() {
909 let gh = GitHub::with_runner(ScriptedRunner::new().on(["api", "user"], Reply::ok("ok\n")));
910 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
911 }
912
913 #[tokio::test]
916 async fn pr_list_parses_scripted_json() {
917 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
918 let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
919 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
920 assert_eq!(prs.len(), 1);
921 assert_eq!(prs[0].number, 7);
922 assert_eq!(prs[0].base_ref_name, "main");
923 }
924
925 #[tokio::test]
929 async fn auth_status_reads_exit_code() {
930 let yes = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
931 assert!(yes.auth_status().await.unwrap());
932 let no = GitHub::with_runner(
933 ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
934 );
935 assert!(!no.auth_status().await.unwrap());
936 let weird = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::fail(2, "boom")));
938 assert!(!weird.auth_status().await.unwrap());
939 }
940
941 #[tokio::test]
945 async fn auth_status_errors_on_timeout() {
946 let gh = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
947 assert!(matches!(
948 gh.auth_status().await.unwrap_err(),
949 Error::Timeout { .. }
950 ));
951 }
952
953 #[tokio::test]
956 async fn pr_create_appends_base_and_returns_url() {
957 let gh = GitHub::with_runner(ScriptedRunner::new().on(
958 [
959 "pr", "create", "--title", "T", "--body", "B", "--base", "main",
960 ],
961 Reply::ok("https://gh/pr/1\n"),
962 ));
963 let url = gh
964 .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
965 .await
966 .expect("should build `pr create … --base main`");
967 assert_eq!(url, "https://gh/pr/1");
968 }
969
970 #[tokio::test]
973 async fn pr_create_appends_head_and_base() {
974 use processkit::RecordingRunner;
975 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
976 let gh = GitHub::with_runner(&rec);
977 gh.pr_create(
978 Path::new("/repo"),
979 PrCreate::new("T", "B").head("feat/x").base("main"),
980 )
981 .await
982 .expect("pr_create");
983 assert_eq!(
984 rec.only_call().args_str(),
985 [
986 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
987 ]
988 );
989 }
990
991 #[tokio::test]
994 async fn pr_list_for_branch_filters_and_parses() {
995 use processkit::RecordingRunner;
996 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
997 let rec = RecordingRunner::replying(Reply::ok(json));
998 let gh = GitHub::with_runner(&rec);
999 let prs = gh
1000 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
1001 .await
1002 .expect("pr_list_for_branch");
1003 assert_eq!(prs.len(), 1);
1004 assert_eq!(prs[0].title, "Merge feat");
1005 assert_eq!(prs[0].url, "https://gh/pr/9");
1006 assert_eq!(
1007 rec.only_call().args_str(),
1008 [
1009 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
1010 "100", "--json", PR_FIELDS
1011 ]
1012 );
1013 }
1014
1015 #[tokio::test]
1018 async fn list_methods_pin_limit_100() {
1019 let rec = RecordingRunner::replying(Reply::ok("[]"));
1020 let gh = GitHub::with_runner(&rec);
1021 gh.pr_list(Path::new("/r")).await.expect("pr_list");
1022 gh.issue_list(Path::new("/r")).await.expect("issue_list");
1023 gh.release_list(Path::new("/r"))
1024 .await
1025 .expect("release_list");
1026 let calls = rec.calls();
1027 assert_eq!(
1028 calls[0].args_str(),
1029 ["pr", "list", "--limit", "100", "--json", PR_FIELDS]
1030 );
1031 assert_eq!(
1032 calls[1].args_str(),
1033 [
1034 "issue",
1035 "list",
1036 "--limit",
1037 "100",
1038 "--json",
1039 ISSUE_LIST_FIELDS
1040 ]
1041 );
1042 assert_eq!(
1043 calls[2].args_str(),
1044 [
1045 "release",
1046 "list",
1047 "--limit",
1048 "100",
1049 "--json",
1050 RELEASE_LIST_FIELDS
1051 ]
1052 );
1053 }
1054
1055 #[tokio::test]
1059 async fn pr_create_omits_base_when_none() {
1060 use processkit::RecordingRunner;
1061 use std::ffi::OsStr;
1062 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
1063 let gh = GitHub::with_runner(&rec);
1064 let url = gh
1065 .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
1066 .await
1067 .expect("pr_create");
1068 assert_eq!(url, "https://gh/pr/2");
1069
1070 let call = rec.only_call();
1071 assert_eq!(call.cwd.as_deref(), Some(OsStr::new("/repo")));
1072 assert_eq!(
1073 call.args_str(),
1074 ["pr", "create", "--title", "T", "--body", "B"]
1075 );
1076 assert!(!call.has_flag("--base"), "no base was given");
1077 assert!(!call.has_flag("--head"), "no head was given");
1078 }
1079
1080 #[tokio::test]
1082 async fn flag_like_positionals_are_rejected_before_spawning() {
1083 let rec = RecordingRunner::replying(Reply::ok(""));
1084 let gh = GitHub::with_runner(&rec);
1085 assert!(gh.api("-evil").await.is_err());
1086 assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
1087 assert!(gh.api("").await.is_err(), "empty refused too");
1088 assert!(rec.calls().is_empty(), "nothing may spawn");
1089 }
1090
1091 #[tokio::test]
1093 async fn pr_merge_builds_strategy_and_flags() {
1094 let rec = RecordingRunner::replying(Reply::ok(""));
1095 let gh = GitHub::with_runner(&rec);
1096 gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
1097 .await
1098 .expect("pr_merge");
1099 assert_eq!(
1100 rec.only_call().args_str(),
1101 ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
1102 );
1103
1104 let bare = RecordingRunner::replying(Reply::ok(""));
1105 let gh = GitHub::with_runner(&bare);
1106 gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
1107 .await
1108 .expect("pr_merge");
1109 let call = bare.only_call();
1110 assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
1111 assert!(!call.has_flag("--auto"));
1112 assert!(!call.has_flag("--delete-branch"));
1113 }
1114
1115 #[tokio::test]
1116 async fn pr_ready_and_close_build_args() {
1117 let rec = RecordingRunner::replying(Reply::ok(""));
1118 let gh = GitHub::with_runner(&rec);
1119 gh.pr_ready(Path::new("/r"), 3).await.expect("pr_ready");
1120 gh.pr_close(Path::new("/r"), 3, true).await.expect("close");
1121 gh.pr_close(Path::new("/r"), 4, false).await.expect("close");
1122 let calls = rec.calls();
1123 assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
1124 assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
1125 assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
1126 }
1127
1128 #[tokio::test]
1132 async fn pr_checks_parses_all_outcome_exit_codes() {
1133 let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
1134 "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
1135 for reply in [
1136 Reply::ok(json),
1137 Reply::fail(8, "checks pending").with_stdout(json),
1138 Reply::fail(1, "some checks failed").with_stdout(json),
1139 ] {
1140 let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "checks"], reply));
1141 let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
1142 assert_eq!(checks.len(), 1);
1143 assert_eq!(checks[0].bucket, "pass");
1144 }
1145
1146 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1149 ["pr", "checks"],
1150 Reply::fail(1, "no checks reported on the 'feat/x' branch"),
1151 ));
1152 assert!(
1153 gh.pr_checks(Path::new("."), 7)
1154 .await
1155 .expect("no checks → empty")
1156 .is_empty()
1157 );
1158 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1160 ["pr", "checks"],
1161 Reply::fail(1, "no pull requests found for branch 'feat/x'"),
1162 ));
1163 assert!(matches!(
1164 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1165 Error::Exit { .. }
1166 ));
1167
1168 let gh = GitHub::with_runner(
1170 ScriptedRunner::new().on(["pr", "checks"], Reply::fail(4, "auth required")),
1171 );
1172 assert!(matches!(
1173 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1174 Error::Exit { .. }
1175 ));
1176
1177 let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "checks"], Reply::timeout()));
1178 assert!(matches!(
1179 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1180 Error::Timeout { .. }
1181 ));
1182 }
1183
1184 #[tokio::test]
1187 async fn pr_review_builds_action_args() {
1188 let rec = RecordingRunner::replying(Reply::ok(""));
1189 let gh = GitHub::with_runner(&rec);
1190 gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
1191 .await
1192 .expect("approve");
1193 gh.pr_review(
1194 Path::new("/r"),
1195 7,
1196 ReviewAction::request_changes("fix the parser"),
1197 )
1198 .await
1199 .expect("request changes");
1200 gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
1201 .await
1202 .expect("comment");
1203 let calls = rec.calls();
1204 assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
1205 assert!(!calls[0].has_flag("--body"));
1206 assert_eq!(
1207 calls[1].args_str(),
1208 [
1209 "pr",
1210 "review",
1211 "7",
1212 "--request-changes",
1213 "--body",
1214 "fix the parser"
1215 ]
1216 );
1217 assert_eq!(
1218 calls[2].args_str(),
1219 ["pr", "review", "7", "--comment", "--body", "nice"]
1220 );
1221 }
1222
1223 #[tokio::test]
1226 async fn pr_review_approve_with_body() {
1227 let action = ReviewAction::approve().with_body("LGTM");
1228 assert_eq!(action.kind(), ReviewKind::Approve);
1229 assert_eq!(action.body(), Some("LGTM"));
1230
1231 let rec = RecordingRunner::replying(Reply::ok(""));
1232 let gh = GitHub::with_runner(&rec);
1233 gh.pr_review(Path::new("/r"), 7, action)
1234 .await
1235 .expect("approve with body");
1236 assert_eq!(
1237 rec.only_call().args_str(),
1238 ["pr", "review", "7", "--approve", "--body", "LGTM"]
1239 );
1240 }
1241
1242 #[tokio::test]
1243 async fn pr_comment_and_issue_create_return_urls() {
1244 let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
1245 let gh = GitHub::with_runner(&rec);
1246 assert_eq!(
1247 gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
1248 "https://gh/x"
1249 );
1250 assert_eq!(
1251 gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
1252 "https://gh/x"
1253 );
1254 let calls = rec.calls();
1255 assert_eq!(
1256 calls[0].args_str(),
1257 ["pr", "comment", "7", "--body", "hello"]
1258 );
1259 assert_eq!(
1260 calls[1].args_str(),
1261 ["issue", "create", "--title", "T", "--body", "B"]
1262 );
1263 }
1264
1265 #[tokio::test]
1266 async fn pr_feedback_requests_reviews_and_comments() {
1267 let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
1268 "body":"","submittedAt":""}],"comments":[]}"#;
1269 let rec = RecordingRunner::new(ScriptedRunner::new().on(["pr", "view"], Reply::ok(json)));
1270 let gh = GitHub::with_runner(&rec);
1271 let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
1272 assert_eq!(feedback.reviews[0].author, "a");
1273 assert!(feedback.comments.is_empty());
1274 assert_eq!(
1275 rec.only_call().args_str(),
1276 ["pr", "view", "7", "--json", "reviews,comments"]
1277 );
1278 }
1279
1280 #[tokio::test]
1282 async fn run_list_appends_branch_only_when_some() {
1283 let rec = RecordingRunner::replying(Reply::ok("[]"));
1284 let gh = GitHub::with_runner(&rec);
1285 gh.run_list(Path::new("/r"), 5, None).await.expect("list");
1286 gh.run_list(Path::new("/r"), 5, Some("main".into()))
1287 .await
1288 .expect("list");
1289 let calls = rec.calls();
1290 assert_eq!(
1291 calls[0].args_str(),
1292 ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
1293 );
1294 assert_eq!(
1295 calls[1].args_str(),
1296 [
1297 "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
1298 ]
1299 );
1300 }
1301
1302 #[tokio::test]
1306 async fn run_watch_then_views_final_state() {
1307 let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
1308 "status":"completed","conclusion":"failure","workflowName":"CI",
1309 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
1310 let rec = RecordingRunner::new(
1311 ScriptedRunner::new()
1312 .on(["run", "watch"], Reply::ok("✓ run completed"))
1313 .on(["run", "view"], Reply::ok(json)),
1314 );
1315 let gh = GitHub::with_runner(&rec);
1316 let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
1317 assert_eq!(run.conclusion, "failure");
1318 let calls = rec.calls();
1319 assert_eq!(calls.len(), 2);
1320 assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
1321 assert_eq!(
1322 calls[1].args_str(),
1323 ["run", "view", "42", "--json", RUN_FIELDS]
1324 );
1325 }
1326
1327 #[tokio::test]
1331 async fn run_watch_surfaces_timeout_and_watch_errors() {
1332 let rec =
1333 RecordingRunner::new(ScriptedRunner::new().on(["run", "watch"], Reply::timeout()));
1334 let gh = GitHub::with_runner(&rec);
1335 assert!(matches!(
1336 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1337 Error::Timeout { .. }
1338 ));
1339 assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
1340
1341 let gh = GitHub::with_runner(
1342 ScriptedRunner::new().on(["run", "watch"], Reply::fail(1, "no such run")),
1343 );
1344 assert!(matches!(
1345 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1346 Error::Exit { .. }
1347 ));
1348 }
1349
1350 #[cfg(feature = "cancellation")]
1358 #[tokio::test(start_paused = true)]
1359 async fn run_watch_cancels_via_client_default_token() {
1360 use processkit::CancellationToken;
1361 let token = CancellationToken::new();
1362 let gh = GitHub::with_runner(ScriptedRunner::new().on(["run", "watch"], Reply::pending()))
1363 .default_cancel_on(token.clone());
1364 let call = gh.run_watch(Path::new("."), 42);
1365 tokio::pin!(call);
1366 assert!(
1367 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
1368 .await
1369 .is_err(),
1370 "run_watch must park until the token fires"
1371 );
1372 token.cancel();
1373 match call.await {
1374 Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
1375 other => panic!("expected Error::Cancelled, got {other:?}"),
1376 }
1377 }
1378
1379 #[tokio::test]
1380 async fn release_view_requests_view_fields() {
1381 let json = r#"{"tagName":"v1","name":"","body":"notes","url":"u",
1382 "publishedAt":"p","isDraft":false,"isPrerelease":false}"#;
1383 let rec =
1384 RecordingRunner::new(ScriptedRunner::new().on(["release", "view"], Reply::ok(json)));
1385 let gh = GitHub::with_runner(&rec);
1386 let release = gh
1387 .release_view(Path::new("."), "v1")
1388 .await
1389 .expect("release_view");
1390 assert_eq!(release.tag_name, "v1");
1391 assert_eq!(release.body, "notes");
1392 assert_eq!(
1393 rec.only_call().args_str(),
1394 ["release", "view", "v1", "--json", RELEASE_VIEW_FIELDS]
1395 );
1396 }
1397
1398 #[tokio::test]
1401 async fn repo_view_parses_scripted_json() {
1402 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
1403 let gh = GitHub::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
1404 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
1405 assert_eq!(repo.owner, "o");
1406 assert_eq!(repo.default_branch, "main");
1407 assert!(!repo.is_private);
1408 }
1409
1410 #[cfg(feature = "mock")]
1411 #[tokio::test]
1412 async fn consumer_mocks_the_interface() {
1413 let mut mock = MockGitHubApi::new();
1414 mock.expect_auth_status().returning(|| Ok(true));
1415 assert!(mock.auth_status().await.unwrap());
1416 }
1417}
1418
1419#[doc = include_str!("../docs/github.md")]
1421#[allow(rustdoc::broken_intra_doc_links)]
1422pub mod guide {}