1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::Path;
116use std::sync::Arc;
117
118use processkit::ProcessRunner;
119use vcs_cli_support::ManagedClient;
122pub use vcs_cli_support::{
123 Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
124 Secret, StaticCredential, provider_fn,
125};
126pub use processkit::{Error, ProcessResult, Result};
129pub use processkit::CancellationToken;
133
134mod parse;
135pub use parse::{
136 CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, Repo, Review,
137 WorkflowRun,
138};
139
140pub const BINARY: &str = "gh";
142
143const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
144const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
145const ISSUE_LIST_FIELDS: &str = "number,title,state,body,url";
146const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url";
147const RUN_FIELDS: &str =
148 "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
149const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
150const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
151const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease";
152
153fn reject_flag_like(what: &str, value: &str) -> Result<()> {
160 vcs_cli_support::reject_flag_like(BINARY, what, value)
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum MergeStrategy {
168 Merge,
170 Squash,
172 Rebase,
174}
175
176impl MergeStrategy {
177 fn flag(self) -> &'static str {
178 match self {
179 MergeStrategy::Merge => "--merge",
180 MergeStrategy::Squash => "--squash",
181 MergeStrategy::Rebase => "--rebase",
182 }
183 }
184}
185
186#[derive(Debug, Clone)]
193#[non_exhaustive]
194pub struct PrMerge {
195 pub strategy: MergeStrategy,
197 pub auto: bool,
199 pub delete_branch: bool,
201}
202
203impl PrMerge {
204 pub fn merge() -> Self {
206 Self::with(MergeStrategy::Merge)
207 }
208
209 pub fn squash() -> Self {
211 Self::with(MergeStrategy::Squash)
212 }
213
214 pub fn rebase() -> Self {
216 Self::with(MergeStrategy::Rebase)
217 }
218
219 fn with(strategy: MergeStrategy) -> Self {
220 Self {
221 strategy,
222 auto: false,
223 delete_branch: false,
224 }
225 }
226
227 pub fn auto(mut self) -> Self {
229 self.auto = true;
230 self
231 }
232
233 pub fn delete_branch(mut self) -> Self {
235 self.delete_branch = true;
236 self
237 }
238}
239
240#[derive(Debug, Clone)]
246#[non_exhaustive]
247pub struct PrCreate {
248 pub title: String,
250 pub body: String,
252 pub head: Option<String>,
254 pub base: Option<String>,
256}
257
258impl PrCreate {
259 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
262 Self {
263 title: title.into(),
264 body: body.into(),
265 head: None,
266 base: None,
267 }
268 }
269
270 pub fn head(mut self, head: impl Into<String>) -> Self {
272 self.head = Some(head.into());
273 self
274 }
275
276 pub fn base(mut self, base: impl Into<String>) -> Self {
278 self.base = Some(base.into());
279 self
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
292#[non_exhaustive]
293pub struct PrEdit {
294 pub title: Option<String>,
296 pub body: Option<String>,
298}
299
300impl PrEdit {
301 pub fn new() -> Self {
305 Self {
306 title: None,
307 body: None,
308 }
309 }
310
311 pub fn title(mut self, title: impl Into<String>) -> Self {
313 self.title = Some(title.into());
314 self
315 }
316
317 pub fn body(mut self, body: impl Into<String>) -> Self {
319 self.body = Some(body.into());
320 self
321 }
322}
323
324impl Default for PrEdit {
325 fn default() -> Self {
326 Self::new()
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333#[non_exhaustive]
334pub enum ReviewKind {
335 Approve,
337 RequestChanges,
339 Comment,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
354#[non_exhaustive]
355pub struct ReviewAction {
356 kind: ReviewKind,
357 body: Option<String>,
358}
359
360impl ReviewAction {
361 pub fn approve() -> Self {
364 Self {
365 kind: ReviewKind::Approve,
366 body: None,
367 }
368 }
369
370 pub fn request_changes(body: impl Into<String>) -> Self {
373 Self {
374 kind: ReviewKind::RequestChanges,
375 body: Some(body.into()),
376 }
377 }
378
379 pub fn comment(body: impl Into<String>) -> Self {
381 Self {
382 kind: ReviewKind::Comment,
383 body: Some(body.into()),
384 }
385 }
386
387 pub fn with_body(mut self, body: impl Into<String>) -> Self {
390 self.body = Some(body.into());
391 self
392 }
393
394 pub fn kind(&self) -> ReviewKind {
396 self.kind
397 }
398
399 pub fn body(&self) -> Option<&str> {
401 self.body.as_deref()
402 }
403}
404
405#[cfg_attr(feature = "mock", mockall::automock)]
408#[async_trait::async_trait]
409pub trait GitHubApi: Send + Sync {
410 async fn run(&self, args: &[String]) -> Result<String>;
412 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
415 async fn version(&self) -> Result<String>;
417 async fn auth_status(&self) -> Result<bool>;
421 async fn repo_view(&self, dir: &Path) -> Result<Repo>;
423 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
426 async fn pr_list_for_branch(
431 &self,
432 dir: &Path,
433 head: &str,
434 base: &str,
435 ) -> Result<Vec<PullRequest>>;
436 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
438 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
441 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
445 async fn api(&self, endpoint: &str) -> Result<String>;
447
448 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
453 async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()>;
455 async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()>;
458 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
465 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
469 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
472 #[allow(unused_variables)]
480 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
481 Err(Error::Unsupported {
482 operation: "pr_edit".into(),
483 })
484 }
485 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
488
489 async fn run_list(
495 &self,
496 dir: &Path,
497 limit: u64,
498 branch: Option<String>,
499 ) -> Result<Vec<WorkflowRun>>;
500 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
503 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
513
514 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
519 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
522 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
526 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
530}
531
532pub struct GitHub<R: ProcessRunner = processkit::JobRunner> {
540 core: ManagedClient<R>,
541}
542
543impl GitHub<processkit::JobRunner> {
544 pub fn new() -> Self {
546 Self {
547 core: ManagedClient::new(BINARY).with_token_env(CredentialService::GitHub, "GH_TOKEN"),
548 }
549 }
550}
551
552impl Default for GitHub<processkit::JobRunner> {
553 fn default() -> Self {
554 Self::new()
555 }
556}
557
558impl<R: ProcessRunner> GitHub<R> {
559 pub fn with_runner(runner: R) -> Self {
561 Self {
562 core: ManagedClient::with_runner(BINARY, runner)
563 .with_token_env(CredentialService::GitHub, "GH_TOKEN"),
564 }
565 }
566
567 pub fn default_timeout(mut self, timeout: std::time::Duration) -> Self {
569 self.core = self.core.default_timeout(timeout);
570 self
571 }
572
573 pub fn default_env(
575 mut self,
576 key: impl AsRef<std::ffi::OsStr>,
577 value: impl AsRef<std::ffi::OsStr>,
578 ) -> Self {
579 self.core = self.core.default_env(key, value);
580 self
581 }
582
583 pub fn default_env_remove(mut self, key: impl AsRef<std::ffi::OsStr>) -> Self {
585 self.core = self.core.default_env_remove(key);
586 self
587 }
588
589 pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
591 self.core = self.core.default_cancel_on(token);
592 self
593 }
594
595 #[must_use]
599 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
600 self.core = self.core.with_credentials(provider);
601 self
602 }
603
604 #[must_use]
608 pub fn with_token(self, token: impl Into<Secret>) -> Self {
609 self.with_credentials(Arc::new(StaticCredential::token(token)))
610 }
611
612 #[must_use]
616 pub fn with_env_token(self, var: impl Into<String>) -> Self {
617 self.with_credentials(Arc::new(EnvToken::new(var)))
618 }
619}
620
621#[async_trait::async_trait]
622impl<R: ProcessRunner> GitHubApi for GitHub<R> {
623 async fn run(&self, args: &[String]) -> Result<String> {
624 self.core.run(args).await
625 }
626
627 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
628 self.core.output(args).await
629 }
630
631 async fn version(&self) -> Result<String> {
632 self.core.run(["--version"]).await
633 }
634
635 async fn auth_status(&self) -> Result<bool> {
636 Ok(self.core.exit_code(["auth", "status"]).await? == 0)
642 }
643
644 async fn repo_view(&self, dir: &Path) -> Result<Repo> {
645 self.core
646 .try_parse(
647 self.core
648 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
649 parse::parse_repo,
650 )
651 .await
652 }
653
654 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
655 self.core
656 .try_parse(
657 self.core
658 .command_in(dir, ["pr", "list", "--limit", "100", "--json", PR_FIELDS]),
659 parse::from_json,
660 )
661 .await
662 }
663
664 async fn pr_list_for_branch(
665 &self,
666 dir: &Path,
667 head: &str,
668 base: &str,
669 ) -> Result<Vec<PullRequest>> {
670 self.core
673 .try_parse(
674 self.core.command_in(
675 dir,
676 [
677 "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
678 "100", "--json", PR_FIELDS,
679 ],
680 ),
681 parse::from_json,
682 )
683 .await
684 }
685
686 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
687 let n = number.to_string();
688 self.core
689 .try_parse(
690 self.core
691 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
692 parse::from_json,
693 )
694 .await
695 }
696
697 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
698 self.core
699 .try_parse(
700 self.core.command_in(
701 dir,
702 [
703 "issue",
704 "list",
705 "--limit",
706 "100",
707 "--json",
708 ISSUE_LIST_FIELDS,
709 ],
710 ),
711 parse::from_json,
712 )
713 .await
714 }
715
716 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
717 let mut args = vec![
718 "pr",
719 "create",
720 "--title",
721 spec.title.as_str(),
722 "--body",
723 spec.body.as_str(),
724 ];
725 if let Some(head) = spec.head.as_deref() {
726 args.push("--head");
727 args.push(head);
728 }
729 if let Some(base) = spec.base.as_deref() {
730 args.push("--base");
731 args.push(base);
732 }
733 self.core.run(self.core.command_in(dir, args)).await
734 }
735
736 async fn api(&self, endpoint: &str) -> Result<String> {
737 reject_flag_like("endpoint", endpoint)?;
738 self.core.run(["api", endpoint]).await
739 }
740
741 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
742 let n = number.to_string();
743 let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
744 if merge.auto {
745 args.push("--auto");
746 }
747 if merge.delete_branch {
748 args.push("--delete-branch");
749 }
750 self.core.run_unit(self.core.command_in(dir, args)).await
751 }
752
753 async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()> {
754 let n = number.to_string();
755 self.core
756 .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
757 .await
758 }
759
760 async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()> {
761 let n = number.to_string();
762 let mut args = vec!["pr", "close", n.as_str()];
763 if delete_branch {
764 args.push("--delete-branch");
765 }
766 self.core.run_unit(self.core.command_in(dir, args)).await
767 }
768
769 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
770 let n = number.to_string();
771 let res = self
772 .core
773 .output(
774 self.core
775 .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
776 )
777 .await?;
778 match res.code() {
779 Some(0) => parse::from_json(res.stdout()),
785 Some(1 | 8) if !res.stdout().trim().is_empty() => parse::from_json(res.stdout()),
786 _ if res
793 .stderr()
794 .to_ascii_lowercase()
795 .contains("no checks reported") =>
796 {
797 Ok(Vec::new())
798 }
799 _ => {
802 res.ensure_success()?;
803 Ok(Vec::new()) }
805 }
806 }
807
808 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
809 let n = number.to_string();
810 let mut args = vec!["pr", "review", n.as_str()];
811 args.push(match action.kind() {
812 ReviewKind::Approve => "--approve",
813 ReviewKind::RequestChanges => "--request-changes",
814 ReviewKind::Comment => "--comment",
815 });
816 if let Some(body) = action.body() {
817 args.push("--body");
818 args.push(body);
819 }
820 self.core.run_unit(self.core.command_in(dir, args)).await
821 }
822
823 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
824 let n = number.to_string();
827 self.core
828 .run(
829 self.core
830 .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
831 )
832 .await
833 }
834
835 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
836 let n = number.to_string();
842 let mut args = vec!["pr", "edit", n.as_str()];
843 if let Some(title) = edit.title.as_deref() {
844 args.push("--title");
845 args.push(title);
846 }
847 if let Some(body) = edit.body.as_deref() {
848 args.push("--body");
849 args.push(body);
850 }
851 self.core.run_unit(self.core.command_in(dir, args)).await
852 }
853
854 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
855 let n = number.to_string();
856 self.core
857 .try_parse(
858 self.core.command_in(
859 dir,
860 ["pr", "view", n.as_str(), "--json", "reviews,comments"],
861 ),
862 parse::parse_feedback,
863 )
864 .await
865 }
866
867 async fn run_list(
868 &self,
869 dir: &Path,
870 limit: u64,
871 branch: Option<String>,
872 ) -> Result<Vec<WorkflowRun>> {
873 let limit = limit.to_string();
874 let mut args = vec!["run", "list", "--limit", limit.as_str()];
875 if let Some(branch) = branch.as_deref() {
876 args.push("--branch");
877 args.push(branch);
878 }
879 args.extend(["--json", RUN_FIELDS]);
880 self.core
881 .try_parse(self.core.command_in(dir, args), parse::from_json)
882 .await
883 }
884
885 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
886 let id = id.to_string();
887 self.core
888 .try_parse(
889 self.core
890 .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
891 parse::from_json,
892 )
893 .await
894 }
895
896 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
897 let id_str = id.to_string();
906 self.core
907 .output(self.core.command_in(dir, ["run", "watch", id_str.as_str()]))
908 .await?
909 .ensure_success()?;
910 self.run_view(dir, id).await
911 }
912
913 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
914 self.core
915 .run(
916 self.core
917 .command_in(dir, ["issue", "create", "--title", title, "--body", body]),
918 )
919 .await
920 }
921
922 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
923 let n = number.to_string();
924 self.core
925 .try_parse(
926 self.core.command_in(
927 dir,
928 ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
929 ),
930 parse::from_json,
931 )
932 .await
933 }
934
935 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
936 self.core
937 .try_parse(
938 self.core.command_in(
939 dir,
940 [
941 "release",
942 "list",
943 "--limit",
944 "100",
945 "--json",
946 RELEASE_LIST_FIELDS,
947 ],
948 ),
949 parse::from_json,
950 )
951 .await
952 }
953
954 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
955 reject_flag_like("tag", tag)?;
956 self.core
957 .try_parse(
958 self.core
959 .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
960 parse::from_json,
961 )
962 .await
963 }
964}
965
966impl<R: ProcessRunner> GitHub<R> {
967 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
972 self.core.run(args).await
973 }
974
975 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
978 self.core.output(args).await
979 }
980
981 pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
985 GitHubAt { gh: self, dir }
986 }
987}
988
989pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
993 gh: &'a GitHub<R>,
994 dir: &'a Path,
995}
996
997impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
1001 fn clone(&self) -> Self {
1002 *self
1003 }
1004}
1005impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
1006
1007macro_rules! github_at_forwarders {
1010 (
1011 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1012 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1013 ) => {
1014 impl<'a, R: ProcessRunner> GitHubAt<'a, R> {
1015 $(
1016 #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($bn), "`.")]
1017 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1018 self.gh.$bn($($ba),*).await
1019 }
1020 )*
1021 $(
1022 #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1023 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1024 self.gh.$dn(self.dir, $($da),*).await
1025 }
1026 )*
1027 }
1028 };
1029}
1030
1031github_at_forwarders! {
1032 bare {
1033 fn run(args: &[String]) -> Result<String>;
1034 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1035 fn run_args(args: &[&str]) -> Result<String>;
1036 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1037 fn version() -> Result<String>;
1038 fn auth_status() -> Result<bool>;
1039 fn api(endpoint: &str) -> Result<String>;
1040 }
1041 dir {
1042 fn repo_view() -> Result<Repo>;
1043 fn pr_list() -> Result<Vec<PullRequest>>;
1044 fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
1045 fn pr_view(number: u64) -> Result<PullRequest>;
1046 fn issue_list() -> Result<Vec<Issue>>;
1047 fn pr_create(spec: PrCreate) -> Result<String>;
1048 fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
1049 fn pr_ready(number: u64) -> Result<()>;
1050 fn pr_close(number: u64, delete_branch: bool) -> Result<()>;
1051 fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
1052 fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
1053 fn pr_comment(number: u64, body: &str) -> Result<String>;
1054 fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
1055 fn pr_feedback(number: u64) -> Result<PrFeedback>;
1056 fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
1057 fn run_view(id: u64) -> Result<WorkflowRun>;
1058 fn run_watch(id: u64) -> Result<WorkflowRun>;
1059 fn issue_create(title: &str, body: &str) -> Result<String>;
1060 fn issue_view(number: u64) -> Result<Issue>;
1061 fn release_list() -> Result<Vec<Release>>;
1062 fn release_view(tag: &str) -> Result<Release>;
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
1070
1071 #[test]
1072 fn binary_name_is_gh() {
1073 assert_eq!(BINARY, "gh");
1074 }
1075
1076 #[allow(dead_code)]
1078 fn bound_view_is_copy_for_default_runner() {
1079 fn assert_copy<T: Copy>() {}
1080 assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
1081 }
1082
1083 #[tokio::test]
1086 async fn bound_view_matches_dir_taking_calls() {
1087 let dir = Path::new("/repo");
1088 let rec = RecordingRunner::replying(Reply::ok("[]"));
1089 let gh = GitHub::with_runner(&rec);
1090
1091 gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
1092 gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
1093 gh.run_list(dir, 3, None).await.unwrap();
1095 gh.at(dir).run_list(3, None).await.unwrap();
1096
1097 let calls = rec.calls();
1098 assert_eq!(calls[0].args_str(), calls[1].args_str());
1099 assert_eq!(calls[2].args_str(), calls[3].args_str());
1100 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
1101 }
1102
1103 #[tokio::test]
1104 async fn run_args_forwards_str_slices() {
1105 let gh =
1106 GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
1107 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
1108 }
1109
1110 #[tokio::test]
1113 async fn pr_list_parses_scripted_json() {
1114 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
1115 let gh =
1116 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "list"], Reply::ok(json)));
1117 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
1118 assert_eq!(prs.len(), 1);
1119 assert_eq!(prs[0].number, 7);
1120 assert_eq!(prs[0].base_ref_name, "main");
1121 }
1122
1123 #[tokio::test]
1127 async fn auth_status_reads_exit_code() {
1128 let yes = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::ok("")));
1129 assert!(yes.auth_status().await.unwrap());
1130 let no = GitHub::with_runner(
1131 ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
1132 );
1133 assert!(!no.auth_status().await.unwrap());
1134 let weird =
1136 GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
1137 assert!(!weird.auth_status().await.unwrap());
1138 }
1139
1140 #[tokio::test]
1144 async fn auth_status_errors_on_timeout() {
1145 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::timeout()));
1146 assert!(matches!(
1147 gh.auth_status().await.unwrap_err(),
1148 Error::Timeout { .. }
1149 ));
1150 }
1151
1152 #[tokio::test]
1155 async fn pr_create_appends_base_and_returns_url() {
1156 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1157 [
1158 "gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
1159 ],
1160 Reply::ok("https://gh/pr/1\n"),
1161 ));
1162 let url = gh
1163 .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
1164 .await
1165 .expect("should build `pr create … --base main`");
1166 assert_eq!(url, "https://gh/pr/1");
1167 }
1168
1169 #[tokio::test]
1172 async fn pr_create_appends_head_and_base() {
1173 use processkit::testing::RecordingRunner;
1174 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
1175 let gh = GitHub::with_runner(&rec);
1176 gh.pr_create(
1177 Path::new("/repo"),
1178 PrCreate::new("T", "B").head("feat/x").base("main"),
1179 )
1180 .await
1181 .expect("pr_create");
1182 assert_eq!(
1183 rec.only_call().args_str(),
1184 [
1185 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
1186 ]
1187 );
1188 }
1189
1190 #[tokio::test]
1193 async fn pr_list_for_branch_filters_and_parses() {
1194 use processkit::testing::RecordingRunner;
1195 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
1196 let rec = RecordingRunner::replying(Reply::ok(json));
1197 let gh = GitHub::with_runner(&rec);
1198 let prs = gh
1199 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
1200 .await
1201 .expect("pr_list_for_branch");
1202 assert_eq!(prs.len(), 1);
1203 assert_eq!(prs[0].title, "Merge feat");
1204 assert_eq!(prs[0].url, "https://gh/pr/9");
1205 assert_eq!(
1206 rec.only_call().args_str(),
1207 [
1208 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
1209 "100", "--json", PR_FIELDS
1210 ]
1211 );
1212 }
1213
1214 #[tokio::test]
1217 async fn list_methods_pin_limit_100() {
1218 let rec = RecordingRunner::replying(Reply::ok("[]"));
1219 let gh = GitHub::with_runner(&rec);
1220 gh.pr_list(Path::new("/r")).await.expect("pr_list");
1221 gh.issue_list(Path::new("/r")).await.expect("issue_list");
1222 gh.release_list(Path::new("/r"))
1223 .await
1224 .expect("release_list");
1225 let calls = rec.calls();
1226 assert_eq!(
1227 calls[0].args_str(),
1228 ["pr", "list", "--limit", "100", "--json", PR_FIELDS]
1229 );
1230 assert_eq!(
1231 calls[1].args_str(),
1232 [
1233 "issue",
1234 "list",
1235 "--limit",
1236 "100",
1237 "--json",
1238 ISSUE_LIST_FIELDS
1239 ]
1240 );
1241 assert_eq!(
1242 calls[2].args_str(),
1243 [
1244 "release",
1245 "list",
1246 "--limit",
1247 "100",
1248 "--json",
1249 RELEASE_LIST_FIELDS
1250 ]
1251 );
1252 }
1253
1254 #[tokio::test]
1258 async fn pr_create_omits_base_when_none() {
1259 use processkit::testing::RecordingRunner;
1260 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
1261 let gh = GitHub::with_runner(&rec);
1262 let url = gh
1263 .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
1264 .await
1265 .expect("pr_create");
1266 assert_eq!(url, "https://gh/pr/2");
1267
1268 let call = rec.only_call();
1269 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
1270 assert_eq!(
1271 call.args_str(),
1272 ["pr", "create", "--title", "T", "--body", "B"]
1273 );
1274 assert!(!call.has_flag("--base"), "no base was given");
1275 assert!(!call.has_flag("--head"), "no head was given");
1276 }
1277
1278 #[tokio::test]
1280 async fn flag_like_positionals_are_rejected_before_spawning() {
1281 let rec = RecordingRunner::replying(Reply::ok(""));
1282 let gh = GitHub::with_runner(&rec);
1283 assert!(gh.api("-evil").await.is_err());
1284 assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
1285 assert!(gh.api("").await.is_err(), "empty refused too");
1286 assert!(rec.calls().is_empty(), "nothing may spawn");
1287 }
1288
1289 #[tokio::test]
1291 async fn pr_merge_builds_strategy_and_flags() {
1292 let rec = RecordingRunner::replying(Reply::ok(""));
1293 let gh = GitHub::with_runner(&rec);
1294 gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
1295 .await
1296 .expect("pr_merge");
1297 assert_eq!(
1298 rec.only_call().args_str(),
1299 ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
1300 );
1301
1302 let bare = RecordingRunner::replying(Reply::ok(""));
1303 let gh = GitHub::with_runner(&bare);
1304 gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
1305 .await
1306 .expect("pr_merge");
1307 let call = bare.only_call();
1308 assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
1309 assert!(!call.has_flag("--auto"));
1310 assert!(!call.has_flag("--delete-branch"));
1311 }
1312
1313 #[tokio::test]
1314 async fn pr_ready_and_close_build_args() {
1315 let rec = RecordingRunner::replying(Reply::ok(""));
1316 let gh = GitHub::with_runner(&rec);
1317 gh.pr_ready(Path::new("/r"), 3).await.expect("pr_ready");
1318 gh.pr_close(Path::new("/r"), 3, true).await.expect("close");
1319 gh.pr_close(Path::new("/r"), 4, false).await.expect("close");
1320 let calls = rec.calls();
1321 assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
1322 assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
1323 assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
1324 }
1325
1326 #[tokio::test]
1330 async fn pr_checks_parses_all_outcome_exit_codes() {
1331 let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
1332 "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
1333 for reply in [
1334 Reply::ok(json),
1335 Reply::fail(8, "checks pending").with_stdout(json),
1336 Reply::fail(1, "some checks failed").with_stdout(json),
1337 ] {
1338 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
1339 let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
1340 assert_eq!(checks.len(), 1);
1341 assert_eq!(checks[0].bucket, CheckBucket::Pass);
1342 }
1343
1344 for stderr in [
1348 "no checks reported on the 'feat/x' branch",
1349 "No Checks Reported on the 'feat/x' branch",
1350 ] {
1351 let gh = GitHub::with_runner(
1352 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
1353 );
1354 assert!(
1355 gh.pr_checks(Path::new("."), 7)
1356 .await
1357 .expect("no checks → empty")
1358 .is_empty(),
1359 "no-checks must read as empty for stderr {stderr:?}"
1360 );
1361 }
1362 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1364 ["gh", "pr", "checks"],
1365 Reply::fail(1, "no pull requests found for branch 'feat/x'"),
1366 ));
1367 assert!(matches!(
1368 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1369 Error::Exit { .. }
1370 ));
1371
1372 let gh = GitHub::with_runner(
1374 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
1375 );
1376 assert!(matches!(
1377 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1378 Error::Exit { .. }
1379 ));
1380
1381 let gh =
1382 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
1383 assert!(matches!(
1384 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1385 Error::Timeout { .. }
1386 ));
1387 }
1388
1389 #[tokio::test]
1392 async fn pr_review_builds_action_args() {
1393 let rec = RecordingRunner::replying(Reply::ok(""));
1394 let gh = GitHub::with_runner(&rec);
1395 gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
1396 .await
1397 .expect("approve");
1398 gh.pr_review(
1399 Path::new("/r"),
1400 7,
1401 ReviewAction::request_changes("fix the parser"),
1402 )
1403 .await
1404 .expect("request changes");
1405 gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
1406 .await
1407 .expect("comment");
1408 let calls = rec.calls();
1409 assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
1410 assert!(!calls[0].has_flag("--body"));
1411 assert_eq!(
1412 calls[1].args_str(),
1413 [
1414 "pr",
1415 "review",
1416 "7",
1417 "--request-changes",
1418 "--body",
1419 "fix the parser"
1420 ]
1421 );
1422 assert_eq!(
1423 calls[2].args_str(),
1424 ["pr", "review", "7", "--comment", "--body", "nice"]
1425 );
1426 }
1427
1428 #[tokio::test]
1431 async fn pr_review_approve_with_body() {
1432 let action = ReviewAction::approve().with_body("LGTM");
1433 assert_eq!(action.kind(), ReviewKind::Approve);
1434 assert_eq!(action.body(), Some("LGTM"));
1435
1436 let rec = RecordingRunner::replying(Reply::ok(""));
1437 let gh = GitHub::with_runner(&rec);
1438 gh.pr_review(Path::new("/r"), 7, action)
1439 .await
1440 .expect("approve with body");
1441 assert_eq!(
1442 rec.only_call().args_str(),
1443 ["pr", "review", "7", "--approve", "--body", "LGTM"]
1444 );
1445 }
1446
1447 #[tokio::test]
1448 async fn pr_comment_and_issue_create_return_urls() {
1449 let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
1450 let gh = GitHub::with_runner(&rec);
1451 assert_eq!(
1452 gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
1453 "https://gh/x"
1454 );
1455 assert_eq!(
1456 gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
1457 "https://gh/x"
1458 );
1459 let calls = rec.calls();
1460 assert_eq!(
1461 calls[0].args_str(),
1462 ["pr", "comment", "7", "--body", "hello"]
1463 );
1464 assert_eq!(
1465 calls[1].args_str(),
1466 ["issue", "create", "--title", "T", "--body", "B"]
1467 );
1468 }
1469
1470 #[tokio::test]
1474 async fn pr_edit_emits_only_provided_fields() {
1475 let rec = RecordingRunner::replying(Reply::ok(""));
1476 let gh = GitHub::with_runner(&rec);
1477
1478 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
1479 .await
1480 .expect("title-only edit");
1481 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
1482 .await
1483 .expect("body-only edit");
1484 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
1485 .await
1486 .expect("both-fields edit");
1487
1488 let calls = rec.calls();
1489 assert_eq!(
1490 calls[0].args_str(),
1491 ["pr", "edit", "7", "--title", "New title"]
1492 );
1493 assert_eq!(
1494 calls[1].args_str(),
1495 ["pr", "edit", "7", "--body", "New body"]
1496 );
1497 assert_eq!(
1498 calls[2].args_str(),
1499 ["pr", "edit", "7", "--title", "T", "--body", "B"]
1500 );
1501 }
1502
1503 #[tokio::test]
1508 async fn pr_edit_some_empty_string_clears_field() {
1509 let rec = RecordingRunner::replying(Reply::ok(""));
1510 let gh = GitHub::with_runner(&rec);
1511 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
1512 .await
1513 .expect("empty title");
1514 assert_eq!(
1515 rec.only_call().args_str(),
1516 ["pr", "edit", "7", "--title", ""]
1517 );
1518 }
1519
1520 #[tokio::test]
1521 async fn with_credentials_injects_gh_token_and_default_does_not() {
1522 let rec = RecordingRunner::replying(Reply::ok("[]"));
1525 let gh = GitHub::with_runner(&rec)
1526 .with_credentials(Arc::new(StaticCredential::token("tok-123")));
1527 gh.pr_list(Path::new("/r")).await.unwrap();
1528 let call = rec.only_call();
1529 let token = call
1530 .envs
1531 .iter()
1532 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1533 .and_then(|(_, v)| v.as_ref())
1534 .and_then(|v| v.to_str());
1535 assert_eq!(
1536 token,
1537 Some("tok-123"),
1538 "provider token injected as GH_TOKEN"
1539 );
1540 assert!(
1541 !call.args_str().iter().any(|a| a.contains("tok-123")),
1542 "secret must never appear in argv"
1543 );
1544
1545 let rec = RecordingRunner::replying(Reply::ok("[]"));
1547 let gh = GitHub::with_runner(&rec);
1548 gh.pr_list(Path::new("/r")).await.unwrap();
1549 assert!(
1550 !rec.only_call()
1551 .envs
1552 .iter()
1553 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
1554 "no provider → no token env (ambient gh auth)"
1555 );
1556 }
1557
1558 #[tokio::test]
1561 async fn with_token_convenience_injects_gh_token() {
1562 let rec = RecordingRunner::replying(Reply::ok("[]"));
1563 let gh = GitHub::with_runner(&rec).with_token("tok-conv");
1564 gh.pr_list(Path::new("/r")).await.unwrap();
1565 let call = rec.only_call();
1566 let token = call
1567 .envs
1568 .iter()
1569 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1570 .and_then(|(_, v)| v.as_ref())
1571 .and_then(|v| v.to_str());
1572 assert_eq!(token, Some("tok-conv"));
1573 }
1574
1575 #[tokio::test]
1579 async fn provider_returning_none_falls_back_to_ambient() {
1580 let rec = RecordingRunner::replying(Reply::ok("[]"));
1581 let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
1582 gh.pr_list(Path::new("/r")).await.unwrap();
1583 assert!(
1584 !rec.only_call()
1585 .envs
1586 .iter()
1587 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
1588 "Ok(None) provider injects no token (ambient)"
1589 );
1590 }
1591
1592 #[tokio::test]
1593 async fn injected_token_overrides_ambient_default_env() {
1594 let rec = RecordingRunner::replying(Reply::ok("[]"));
1597 let gh = GitHub::with_runner(&rec)
1598 .default_env("GH_TOKEN", "ambient-token")
1599 .with_credentials(Arc::new(StaticCredential::token("provider-token")));
1600 gh.pr_list(Path::new("/r")).await.unwrap();
1601 let call = rec.only_call();
1602 let winner = call
1603 .envs
1604 .iter()
1605 .rev()
1606 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1607 .and_then(|(_, v)| v.as_ref())
1608 .and_then(|v| v.to_str());
1609 assert_eq!(winner, Some("provider-token"), "provider token wins");
1610 }
1611
1612 #[tokio::test]
1613 async fn pr_feedback_requests_reviews_and_comments() {
1614 let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
1615 "body":"","submittedAt":""}],"comments":[]}"#;
1616 let rec =
1617 RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
1618 let gh = GitHub::with_runner(&rec);
1619 let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
1620 assert_eq!(feedback.reviews[0].author, "a");
1621 assert!(feedback.comments.is_empty());
1622 assert_eq!(
1623 rec.only_call().args_str(),
1624 ["pr", "view", "7", "--json", "reviews,comments"]
1625 );
1626 }
1627
1628 #[tokio::test]
1630 async fn run_list_appends_branch_only_when_some() {
1631 let rec = RecordingRunner::replying(Reply::ok("[]"));
1632 let gh = GitHub::with_runner(&rec);
1633 gh.run_list(Path::new("/r"), 5, None).await.expect("list");
1634 gh.run_list(Path::new("/r"), 5, Some("main".into()))
1635 .await
1636 .expect("list");
1637 let calls = rec.calls();
1638 assert_eq!(
1639 calls[0].args_str(),
1640 ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
1641 );
1642 assert_eq!(
1643 calls[1].args_str(),
1644 [
1645 "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
1646 ]
1647 );
1648 }
1649
1650 #[tokio::test]
1654 async fn run_watch_then_views_final_state() {
1655 let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
1656 "status":"completed","conclusion":"failure","workflowName":"CI",
1657 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
1658 let rec = RecordingRunner::new(
1659 ScriptedRunner::new()
1660 .on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
1661 .on(["gh", "run", "view"], Reply::ok(json)),
1662 );
1663 let gh = GitHub::with_runner(&rec);
1664 let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
1665 assert_eq!(run.conclusion, "failure");
1666 let calls = rec.calls();
1667 assert_eq!(calls.len(), 2);
1668 assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
1669 assert_eq!(
1670 calls[1].args_str(),
1671 ["run", "view", "42", "--json", RUN_FIELDS]
1672 );
1673 }
1674
1675 #[tokio::test]
1679 async fn run_watch_surfaces_timeout_and_watch_errors() {
1680 let rec = RecordingRunner::new(
1681 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
1682 );
1683 let gh = GitHub::with_runner(&rec);
1684 assert!(matches!(
1685 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1686 Error::Timeout { .. }
1687 ));
1688 assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
1689
1690 let gh = GitHub::with_runner(
1691 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
1692 );
1693 assert!(matches!(
1694 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1695 Error::Exit { .. }
1696 ));
1697 }
1698
1699 #[tokio::test(start_paused = true)]
1707 async fn run_watch_cancels_via_client_default_token() {
1708 use processkit::CancellationToken;
1709 let token = CancellationToken::new();
1710 let gh =
1711 GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
1712 .default_cancel_on(token.clone());
1713 let call = gh.run_watch(Path::new("."), 42);
1714 tokio::pin!(call);
1715 assert!(
1716 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
1717 .await
1718 .is_err(),
1719 "run_watch must park until the token fires"
1720 );
1721 token.cancel();
1722 match call.await {
1723 Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
1724 other => panic!("expected Error::Cancelled, got {other:?}"),
1725 }
1726 }
1727
1728 #[tokio::test]
1729 async fn release_view_requests_view_fields() {
1730 let json = r#"{"tagName":"v1","name":"","body":"notes","url":"u",
1731 "publishedAt":"p","isDraft":false,"isPrerelease":false}"#;
1732 let rec = RecordingRunner::new(
1733 ScriptedRunner::new().on(["gh", "release", "view"], Reply::ok(json)),
1734 );
1735 let gh = GitHub::with_runner(&rec);
1736 let release = gh
1737 .release_view(Path::new("."), "v1")
1738 .await
1739 .expect("release_view");
1740 assert_eq!(release.tag_name, "v1");
1741 assert_eq!(release.body, "notes");
1742 assert_eq!(
1743 rec.only_call().args_str(),
1744 ["release", "view", "v1", "--json", RELEASE_VIEW_FIELDS]
1745 );
1746 }
1747
1748 #[tokio::test]
1751 async fn repo_view_parses_scripted_json() {
1752 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
1753 let gh =
1754 GitHub::with_runner(ScriptedRunner::new().on(["gh", "repo", "view"], Reply::ok(json)));
1755 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
1756 assert_eq!(repo.owner, "o");
1757 assert_eq!(repo.default_branch, "main");
1758 assert!(!repo.is_private);
1759 }
1760
1761 #[cfg(feature = "mock")]
1762 #[tokio::test]
1763 async fn consumer_mocks_the_interface() {
1764 let mut mock = MockGitHubApi::new();
1765 mock.expect_auth_status().returning(|| Ok(true));
1766 assert!(mock.auth_status().await.unwrap());
1767 }
1768}
1769
1770#[doc = include_str!("../docs/github.md")]
1772#[allow(rustdoc::broken_intra_doc_links)]
1773pub mod guide {}