1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::Path;
116use std::sync::Arc;
117
118pub use vcs_cli_support::{
122 Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
123 Secret, StaticCredential, provider_fn,
124};
125pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
130pub use processkit::CancellationToken;
134
135mod parse;
136pub use parse::{
137 CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, RepoView, Review,
138 WorkflowRun,
139};
140
141pub const BINARY: &str = "gh";
143
144const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
145const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
146const ISSUE_LIST_FIELDS: &str = "number,title,state,body,url";
147const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url";
148const RUN_FIELDS: &str =
149 "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
150const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
151const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
152const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease";
153
154fn reject_flag_like(what: &str, value: &str) -> Result<()> {
161 vcs_cli_support::reject_flag_like(BINARY, what, value)
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167#[non_exhaustive]
168pub enum MergeStrategy {
169 Merge,
171 Squash,
173 Rebase,
175}
176
177impl MergeStrategy {
178 fn flag(self) -> &'static str {
179 match self {
180 MergeStrategy::Merge => "--merge",
181 MergeStrategy::Squash => "--squash",
182 MergeStrategy::Rebase => "--rebase",
183 }
184 }
185}
186
187#[derive(Debug, Clone)]
194#[non_exhaustive]
195pub struct PrMerge {
196 pub strategy: MergeStrategy,
198 pub auto: bool,
200 pub delete_branch: bool,
202}
203
204impl PrMerge {
205 pub fn merge() -> Self {
207 Self::with(MergeStrategy::Merge)
208 }
209
210 pub fn squash() -> Self {
212 Self::with(MergeStrategy::Squash)
213 }
214
215 pub fn rebase() -> Self {
217 Self::with(MergeStrategy::Rebase)
218 }
219
220 fn with(strategy: MergeStrategy) -> Self {
221 Self {
222 strategy,
223 auto: false,
224 delete_branch: false,
225 }
226 }
227
228 pub fn auto(mut self) -> Self {
230 self.auto = true;
231 self
232 }
233
234 pub fn delete_branch(mut self) -> Self {
236 self.delete_branch = true;
237 self
238 }
239}
240
241#[derive(Debug, Clone)]
247#[non_exhaustive]
248pub struct PrCreate {
249 pub title: String,
251 pub body: String,
253 pub head: Option<String>,
255 pub base: Option<String>,
257}
258
259impl PrCreate {
260 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
263 Self {
264 title: title.into(),
265 body: body.into(),
266 head: None,
267 base: None,
268 }
269 }
270
271 pub fn head(mut self, head: impl Into<String>) -> Self {
273 self.head = Some(head.into());
274 self
275 }
276
277 pub fn base(mut self, base: impl Into<String>) -> Self {
279 self.base = Some(base.into());
280 self
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
293#[non_exhaustive]
294pub struct PrEdit {
295 pub title: Option<String>,
297 pub body: Option<String>,
299}
300
301impl PrEdit {
302 pub fn new() -> Self {
306 Self {
307 title: None,
308 body: None,
309 }
310 }
311
312 pub fn title(mut self, title: impl Into<String>) -> Self {
314 self.title = Some(title.into());
315 self
316 }
317
318 pub fn body(mut self, body: impl Into<String>) -> Self {
320 self.body = Some(body.into());
321 self
322 }
323}
324
325impl Default for PrEdit {
326 fn default() -> Self {
327 Self::new()
328 }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334#[non_exhaustive]
335pub enum ReviewKind {
336 Approve,
338 RequestChanges,
340 Comment,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
355#[non_exhaustive]
356pub struct ReviewAction {
357 kind: ReviewKind,
358 body: Option<String>,
359}
360
361impl ReviewAction {
362 pub fn approve() -> Self {
365 Self {
366 kind: ReviewKind::Approve,
367 body: None,
368 }
369 }
370
371 pub fn request_changes(body: impl Into<String>) -> Self {
374 Self {
375 kind: ReviewKind::RequestChanges,
376 body: Some(body.into()),
377 }
378 }
379
380 pub fn comment(body: impl Into<String>) -> Self {
382 Self {
383 kind: ReviewKind::Comment,
384 body: Some(body.into()),
385 }
386 }
387
388 pub fn with_body(mut self, body: impl Into<String>) -> Self {
391 self.body = Some(body.into());
392 self
393 }
394
395 pub fn kind(&self) -> ReviewKind {
397 self.kind
398 }
399
400 pub fn body(&self) -> Option<&str> {
402 self.body.as_deref()
403 }
404}
405
406#[cfg_attr(feature = "mock", mockall::automock)]
409#[async_trait::async_trait]
410pub trait GitHubApi: Send + Sync {
411 async fn run(&self, args: &[String]) -> Result<String>;
416 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
419 async fn version(&self) -> Result<String>;
421 async fn auth_status(&self) -> Result<bool>;
425 async fn repo_view(&self, dir: &Path) -> Result<RepoView>;
427 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
430 async fn pr_list_for_branch(
435 &self,
436 dir: &Path,
437 head: &str,
438 base: &str,
439 ) -> Result<Vec<PullRequest>>;
440 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
442 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
445 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
449 async fn api(&self, dir: &Path, endpoint: &str) -> Result<String>;
453
454 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
459 async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()>;
461 async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()>;
464 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
471 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
475 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
478 #[allow(unused_variables)]
486 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
487 Err(Error::Unsupported {
488 operation: "pr_edit".into(),
489 })
490 }
491 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
494
495 async fn run_list(
501 &self,
502 dir: &Path,
503 limit: u64,
504 branch: Option<String>,
505 ) -> Result<Vec<WorkflowRun>>;
506 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
509 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
519
520 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
525 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
528 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
532 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
536}
537
538vcs_cli_support::managed_client! {
539 pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
547}
548
549impl<R: ProcessRunner> GitHub<R> {
550 #[must_use]
554 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
555 self.core = self.core.with_credentials(provider);
556 self
557 }
558
559 #[must_use]
563 pub fn with_token(self, token: impl Into<Secret>) -> Self {
564 self.with_credentials(Arc::new(StaticCredential::token(token)))
565 }
566
567 #[must_use]
571 pub fn with_env_token(self, var: impl Into<String>) -> Self {
572 self.with_credentials(Arc::new(EnvToken::new(var)))
573 }
574}
575
576#[async_trait::async_trait]
577impl<R: ProcessRunner> GitHubApi for GitHub<R> {
578 async fn run(&self, args: &[String]) -> Result<String> {
579 self.core.run(args).await
580 }
581
582 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
583 self.core.output_string(args).await
584 }
585
586 async fn version(&self) -> Result<String> {
587 self.core.run(["--version"]).await
588 }
589
590 async fn auth_status(&self) -> Result<bool> {
591 Ok(self.core.exit_code(["auth", "status"]).await? == 0)
597 }
598
599 async fn repo_view(&self, dir: &Path) -> Result<RepoView> {
600 self.core
601 .try_parse(
602 self.core
603 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
604 parse::parse_repo,
605 )
606 .await
607 }
608
609 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
610 self.core
611 .try_parse(
612 self.core
613 .command_in(dir, ["pr", "list", "--limit", "100", "--json", PR_FIELDS]),
614 |s| vcs_cli_support::json::from_json(BINARY, s),
615 )
616 .await
617 }
618
619 async fn pr_list_for_branch(
620 &self,
621 dir: &Path,
622 head: &str,
623 base: &str,
624 ) -> Result<Vec<PullRequest>> {
625 self.core
628 .try_parse(
629 self.core.command_in(
630 dir,
631 [
632 "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
633 "100", "--json", PR_FIELDS,
634 ],
635 ),
636 |s| vcs_cli_support::json::from_json(BINARY, s),
637 )
638 .await
639 }
640
641 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
642 let n = number.to_string();
643 self.core
644 .try_parse(
645 self.core
646 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
647 |s| vcs_cli_support::json::from_json(BINARY, s),
648 )
649 .await
650 }
651
652 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
653 self.core
654 .try_parse(
655 self.core.command_in(
656 dir,
657 [
658 "issue",
659 "list",
660 "--limit",
661 "100",
662 "--json",
663 ISSUE_LIST_FIELDS,
664 ],
665 ),
666 |s| vcs_cli_support::json::from_json(BINARY, s),
667 )
668 .await
669 }
670
671 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
672 let mut args = vec![
673 "pr",
674 "create",
675 "--title",
676 spec.title.as_str(),
677 "--body",
678 spec.body.as_str(),
679 ];
680 if let Some(head) = spec.head.as_deref() {
681 args.push("--head");
682 args.push(head);
683 }
684 if let Some(base) = spec.base.as_deref() {
685 args.push("--base");
686 args.push(base);
687 }
688 self.core.run(self.core.command_in(dir, args)).await
689 }
690
691 async fn api(&self, dir: &Path, endpoint: &str) -> Result<String> {
692 reject_flag_like("endpoint", endpoint)?;
693 self.core
694 .run(self.core.command_in(dir, ["api", endpoint]))
695 .await
696 }
697
698 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
699 let n = number.to_string();
700 let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
701 if merge.auto {
702 args.push("--auto");
703 }
704 if merge.delete_branch {
705 args.push("--delete-branch");
706 }
707 self.core.run_unit(self.core.command_in(dir, args)).await
708 }
709
710 async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()> {
711 let n = number.to_string();
712 self.core
713 .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
714 .await
715 }
716
717 async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()> {
718 let n = number.to_string();
719 let mut args = vec!["pr", "close", n.as_str()];
720 if delete_branch {
721 args.push("--delete-branch");
722 }
723 self.core.run_unit(self.core.command_in(dir, args)).await
724 }
725
726 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
727 let n = number.to_string();
728 let res = self
729 .core
730 .output_string(
731 self.core
732 .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
733 )
734 .await?;
735 match res.code() {
736 Some(0) => vcs_cli_support::json::from_json(BINARY, res.stdout()),
742 Some(1 | 8) if !res.stdout().trim().is_empty() => {
743 vcs_cli_support::json::from_json(BINARY, res.stdout())
744 }
745 _ if res
752 .stderr()
753 .to_ascii_lowercase()
754 .contains("no checks reported") =>
755 {
756 Ok(Vec::new())
757 }
758 _ => {
761 let _ = res.ensure_success()?;
762 Ok(Vec::new()) }
764 }
765 }
766
767 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
768 let n = number.to_string();
769 let mut args = vec!["pr", "review", n.as_str()];
770 args.push(match action.kind() {
771 ReviewKind::Approve => "--approve",
772 ReviewKind::RequestChanges => "--request-changes",
773 ReviewKind::Comment => "--comment",
774 });
775 if let Some(body) = action.body() {
776 args.push("--body");
777 args.push(body);
778 }
779 self.core.run_unit(self.core.command_in(dir, args)).await
780 }
781
782 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
783 let n = number.to_string();
786 self.core
787 .run(
788 self.core
789 .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
790 )
791 .await
792 }
793
794 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
795 let n = number.to_string();
801 let mut args = vec!["pr", "edit", n.as_str()];
802 if let Some(title) = edit.title.as_deref() {
803 args.push("--title");
804 args.push(title);
805 }
806 if let Some(body) = edit.body.as_deref() {
807 args.push("--body");
808 args.push(body);
809 }
810 self.core.run_unit(self.core.command_in(dir, args)).await
811 }
812
813 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
814 let n = number.to_string();
815 self.core
816 .try_parse(
817 self.core.command_in(
818 dir,
819 ["pr", "view", n.as_str(), "--json", "reviews,comments"],
820 ),
821 parse::parse_feedback,
822 )
823 .await
824 }
825
826 async fn run_list(
827 &self,
828 dir: &Path,
829 limit: u64,
830 branch: Option<String>,
831 ) -> Result<Vec<WorkflowRun>> {
832 let limit = limit.to_string();
833 let mut args = vec!["run", "list", "--limit", limit.as_str()];
834 if let Some(branch) = branch.as_deref() {
835 args.push("--branch");
836 args.push(branch);
837 }
838 args.extend(["--json", RUN_FIELDS]);
839 self.core
840 .try_parse(self.core.command_in(dir, args), |s| {
841 vcs_cli_support::json::from_json(BINARY, s)
842 })
843 .await
844 }
845
846 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
847 let id = id.to_string();
848 self.core
849 .try_parse(
850 self.core
851 .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
852 |s| vcs_cli_support::json::from_json(BINARY, s),
853 )
854 .await
855 }
856
857 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
858 let id_str = id.to_string();
867 let cmd = self
874 .core
875 .command_in(dir, ["run", "watch", id_str.as_str()])
876 .output_buffer(processkit::OutputBufferPolicy::bounded(256).with_max_bytes(256 * 1024));
877 let _ = self.core.output_string(cmd).await?.ensure_success()?;
878 self.run_view(dir, id).await
879 }
880
881 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
882 self.core
883 .run(
884 self.core
885 .command_in(dir, ["issue", "create", "--title", title, "--body", body]),
886 )
887 .await
888 }
889
890 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
891 let n = number.to_string();
892 self.core
893 .try_parse(
894 self.core.command_in(
895 dir,
896 ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
897 ),
898 |s| vcs_cli_support::json::from_json(BINARY, s),
899 )
900 .await
901 }
902
903 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
904 self.core
905 .try_parse(
906 self.core.command_in(
907 dir,
908 [
909 "release",
910 "list",
911 "--limit",
912 "100",
913 "--json",
914 RELEASE_LIST_FIELDS,
915 ],
916 ),
917 |s| vcs_cli_support::json::from_json(BINARY, s),
918 )
919 .await
920 }
921
922 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
923 reject_flag_like("tag", tag)?;
924 self.core
925 .try_parse(
926 self.core
927 .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
928 |s| vcs_cli_support::json::from_json(BINARY, s),
929 )
930 .await
931 }
932}
933
934impl<R: ProcessRunner> GitHub<R> {
935 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
940 self.core.run(args).await
941 }
942
943 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
946 self.core.output_string(args).await
947 }
948
949 pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
953 GitHubAt { gh: self, dir }
954 }
955}
956
957pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
961 gh: &'a GitHub<R>,
962 dir: &'a Path,
963}
964
965impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
969 fn clone(&self) -> Self {
970 *self
971 }
972}
973impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
974
975vcs_cli_support::at_forwarders! {
979 GitHubAt, gh, "GitHub",
980 bare {
981 fn run(args: &[String]) -> Result<String>;
982 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
983 fn run_args(args: &[&str]) -> Result<String>;
984 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
985 fn version() -> Result<String>;
986 fn auth_status() -> Result<bool>;
987 }
988 dir {
989 fn api(endpoint: &str) -> Result<String>;
990 fn repo_view() -> Result<RepoView>;
991 fn pr_list() -> Result<Vec<PullRequest>>;
992 fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
993 fn pr_view(number: u64) -> Result<PullRequest>;
994 fn issue_list() -> Result<Vec<Issue>>;
995 fn pr_create(spec: PrCreate) -> Result<String>;
996 fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
997 fn pr_mark_ready(number: u64) -> Result<()>;
998 fn pr_close(number: u64, delete_branch: bool) -> Result<()>;
999 fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
1000 fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
1001 fn pr_comment(number: u64, body: &str) -> Result<String>;
1002 fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
1003 fn pr_feedback(number: u64) -> Result<PrFeedback>;
1004 fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
1005 fn run_view(id: u64) -> Result<WorkflowRun>;
1006 fn run_watch(id: u64) -> Result<WorkflowRun>;
1007 fn issue_create(title: &str, body: &str) -> Result<String>;
1008 fn issue_view(number: u64) -> Result<Issue>;
1009 fn release_list() -> Result<Vec<Release>>;
1010 fn release_view(tag: &str) -> Result<Release>;
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
1018
1019 #[test]
1020 fn binary_name_is_gh() {
1021 assert_eq!(BINARY, "gh");
1022 }
1023
1024 #[allow(dead_code)]
1026 fn bound_view_is_copy_for_default_runner() {
1027 fn assert_copy<T: Copy>() {}
1028 assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
1029 }
1030
1031 #[tokio::test]
1034 async fn bound_view_matches_dir_taking_calls() {
1035 let dir = Path::new("/repo");
1036 let rec = RecordingRunner::replying(Reply::ok("[]"));
1037 let gh = GitHub::with_runner(&rec);
1038
1039 gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
1040 gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
1041 gh.run_list(dir, 3, None).await.unwrap();
1043 gh.at(dir).run_list(3, None).await.unwrap();
1044
1045 let calls = rec.calls();
1046 assert_eq!(calls[0].args_str(), calls[1].args_str());
1047 assert_eq!(calls[2].args_str(), calls[3].args_str());
1048 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
1049 }
1050
1051 #[tokio::test]
1052 async fn run_args_forwards_str_slices() {
1053 let gh =
1054 GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
1055 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
1056 }
1057
1058 #[tokio::test]
1061 async fn pr_list_parses_scripted_json() {
1062 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
1063 let gh =
1064 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "list"], Reply::ok(json)));
1065 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
1066 assert_eq!(prs.len(), 1);
1067 assert_eq!(prs[0].number, 7);
1068 assert_eq!(prs[0].base_ref_name, "main");
1069 }
1070
1071 #[tokio::test]
1075 async fn auth_status_reads_exit_code() {
1076 let yes = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::ok("")));
1077 assert!(yes.auth_status().await.unwrap());
1078 let no = GitHub::with_runner(
1079 ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
1080 );
1081 assert!(!no.auth_status().await.unwrap());
1082 let weird =
1084 GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
1085 assert!(!weird.auth_status().await.unwrap());
1086 }
1087
1088 #[tokio::test]
1092 async fn auth_status_errors_on_timeout() {
1093 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::timeout()));
1094 assert!(matches!(
1095 gh.auth_status().await.unwrap_err(),
1096 Error::Timeout { .. }
1097 ));
1098 }
1099
1100 #[tokio::test]
1103 async fn pr_create_appends_base_and_returns_url() {
1104 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1105 [
1106 "gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
1107 ],
1108 Reply::ok("https://gh/pr/1\n"),
1109 ));
1110 let url = gh
1111 .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
1112 .await
1113 .expect("should build `pr create … --base main`");
1114 assert_eq!(url, "https://gh/pr/1");
1115 }
1116
1117 #[tokio::test]
1120 async fn pr_create_appends_head_and_base() {
1121 use processkit::testing::RecordingRunner;
1122 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
1123 let gh = GitHub::with_runner(&rec);
1124 gh.pr_create(
1125 Path::new("/repo"),
1126 PrCreate::new("T", "B").head("feat/x").base("main"),
1127 )
1128 .await
1129 .expect("pr_create");
1130 assert_eq!(
1131 rec.only_call().args_str(),
1132 [
1133 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
1134 ]
1135 );
1136 }
1137
1138 #[tokio::test]
1141 async fn pr_list_for_branch_filters_and_parses() {
1142 use processkit::testing::RecordingRunner;
1143 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
1144 let rec = RecordingRunner::replying(Reply::ok(json));
1145 let gh = GitHub::with_runner(&rec);
1146 let prs = gh
1147 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
1148 .await
1149 .expect("pr_list_for_branch");
1150 assert_eq!(prs.len(), 1);
1151 assert_eq!(prs[0].title, "Merge feat");
1152 assert_eq!(prs[0].url, "https://gh/pr/9");
1153 assert_eq!(
1154 rec.only_call().args_str(),
1155 [
1156 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
1157 "100", "--json", PR_FIELDS
1158 ]
1159 );
1160 }
1161
1162 #[tokio::test]
1165 async fn list_methods_pin_limit_100() {
1166 let rec = RecordingRunner::replying(Reply::ok("[]"));
1167 let gh = GitHub::with_runner(&rec);
1168 gh.pr_list(Path::new("/r")).await.expect("pr_list");
1169 gh.issue_list(Path::new("/r")).await.expect("issue_list");
1170 gh.release_list(Path::new("/r"))
1171 .await
1172 .expect("release_list");
1173 let calls = rec.calls();
1174 assert_eq!(
1175 calls[0].args_str(),
1176 ["pr", "list", "--limit", "100", "--json", PR_FIELDS]
1177 );
1178 assert_eq!(
1179 calls[1].args_str(),
1180 [
1181 "issue",
1182 "list",
1183 "--limit",
1184 "100",
1185 "--json",
1186 ISSUE_LIST_FIELDS
1187 ]
1188 );
1189 assert_eq!(
1190 calls[2].args_str(),
1191 [
1192 "release",
1193 "list",
1194 "--limit",
1195 "100",
1196 "--json",
1197 RELEASE_LIST_FIELDS
1198 ]
1199 );
1200 }
1201
1202 #[tokio::test]
1206 async fn pr_create_omits_base_when_none() {
1207 use processkit::testing::RecordingRunner;
1208 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
1209 let gh = GitHub::with_runner(&rec);
1210 let url = gh
1211 .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
1212 .await
1213 .expect("pr_create");
1214 assert_eq!(url, "https://gh/pr/2");
1215
1216 let call = rec.only_call();
1217 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
1218 assert_eq!(
1219 call.args_str(),
1220 ["pr", "create", "--title", "T", "--body", "B"]
1221 );
1222 assert!(!call.has_flag("--base"), "no base was given");
1223 assert!(!call.has_flag("--head"), "no head was given");
1224 }
1225
1226 #[tokio::test]
1228 async fn flag_like_positionals_are_rejected_before_spawning() {
1229 let rec = RecordingRunner::replying(Reply::ok(""));
1230 let gh = GitHub::with_runner(&rec);
1231 assert!(gh.api(Path::new("."), "-evil").await.is_err());
1232 assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
1233 assert!(
1234 gh.api(Path::new("."), "").await.is_err(),
1235 "empty refused too"
1236 );
1237 assert!(rec.calls().is_empty(), "nothing may spawn");
1238 }
1239
1240 #[tokio::test]
1241 async fn api_runs_in_the_bound_repo_dir() {
1242 let rec = RecordingRunner::replying(Reply::ok("{}\n"));
1243 let gh = GitHub::with_runner(&rec);
1244 gh.api(Path::new("/repo"), "repos/o/r/pulls")
1245 .await
1246 .expect("api");
1247 let call = rec.only_call();
1248 assert_eq!(call.args_str(), ["api", "repos/o/r/pulls"]);
1249 assert_eq!(call.cwd, Some(std::path::PathBuf::from("/repo")));
1252 }
1253
1254 #[tokio::test]
1256 async fn pr_merge_builds_strategy_and_flags() {
1257 let rec = RecordingRunner::replying(Reply::ok(""));
1258 let gh = GitHub::with_runner(&rec);
1259 gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
1260 .await
1261 .expect("pr_merge");
1262 assert_eq!(
1263 rec.only_call().args_str(),
1264 ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
1265 );
1266
1267 let bare = RecordingRunner::replying(Reply::ok(""));
1268 let gh = GitHub::with_runner(&bare);
1269 gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
1270 .await
1271 .expect("pr_merge");
1272 let call = bare.only_call();
1273 assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
1274 assert!(!call.has_flag("--auto"));
1275 assert!(!call.has_flag("--delete-branch"));
1276 }
1277
1278 #[tokio::test]
1279 async fn pr_mark_ready_and_close_build_args() {
1280 let rec = RecordingRunner::replying(Reply::ok(""));
1281 let gh = GitHub::with_runner(&rec);
1282 gh.pr_mark_ready(Path::new("/r"), 3)
1283 .await
1284 .expect("pr_mark_ready");
1285 gh.pr_close(Path::new("/r"), 3, true).await.expect("close");
1286 gh.pr_close(Path::new("/r"), 4, false).await.expect("close");
1287 let calls = rec.calls();
1288 assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
1289 assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
1290 assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
1291 }
1292
1293 #[tokio::test]
1297 async fn pr_checks_parses_all_outcome_exit_codes() {
1298 let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
1299 "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
1300 for reply in [
1301 Reply::ok(json),
1302 Reply::fail(8, "checks pending").with_stdout(json),
1303 Reply::fail(1, "some checks failed").with_stdout(json),
1304 ] {
1305 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
1306 let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
1307 assert_eq!(checks.len(), 1);
1308 assert_eq!(checks[0].bucket, CheckBucket::Pass);
1309 }
1310
1311 for stderr in [
1315 "no checks reported on the 'feat/x' branch",
1316 "No Checks Reported on the 'feat/x' branch",
1317 ] {
1318 let gh = GitHub::with_runner(
1319 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
1320 );
1321 assert!(
1322 gh.pr_checks(Path::new("."), 7)
1323 .await
1324 .expect("no checks → empty")
1325 .is_empty(),
1326 "no-checks must read as empty for stderr {stderr:?}"
1327 );
1328 }
1329 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1331 ["gh", "pr", "checks"],
1332 Reply::fail(1, "no pull requests found for branch 'feat/x'"),
1333 ));
1334 assert!(matches!(
1335 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1336 Error::Exit { .. }
1337 ));
1338
1339 let gh = GitHub::with_runner(
1341 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
1342 );
1343 assert!(matches!(
1344 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1345 Error::Exit { .. }
1346 ));
1347
1348 let gh =
1349 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
1350 assert!(matches!(
1351 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1352 Error::Timeout { .. }
1353 ));
1354 }
1355
1356 #[tokio::test]
1359 async fn pr_review_builds_action_args() {
1360 let rec = RecordingRunner::replying(Reply::ok(""));
1361 let gh = GitHub::with_runner(&rec);
1362 gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
1363 .await
1364 .expect("approve");
1365 gh.pr_review(
1366 Path::new("/r"),
1367 7,
1368 ReviewAction::request_changes("fix the parser"),
1369 )
1370 .await
1371 .expect("request changes");
1372 gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
1373 .await
1374 .expect("comment");
1375 let calls = rec.calls();
1376 assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
1377 assert!(!calls[0].has_flag("--body"));
1378 assert_eq!(
1379 calls[1].args_str(),
1380 [
1381 "pr",
1382 "review",
1383 "7",
1384 "--request-changes",
1385 "--body",
1386 "fix the parser"
1387 ]
1388 );
1389 assert_eq!(
1390 calls[2].args_str(),
1391 ["pr", "review", "7", "--comment", "--body", "nice"]
1392 );
1393 }
1394
1395 #[tokio::test]
1398 async fn pr_review_approve_with_body() {
1399 let action = ReviewAction::approve().with_body("LGTM");
1400 assert_eq!(action.kind(), ReviewKind::Approve);
1401 assert_eq!(action.body(), Some("LGTM"));
1402
1403 let rec = RecordingRunner::replying(Reply::ok(""));
1404 let gh = GitHub::with_runner(&rec);
1405 gh.pr_review(Path::new("/r"), 7, action)
1406 .await
1407 .expect("approve with body");
1408 assert_eq!(
1409 rec.only_call().args_str(),
1410 ["pr", "review", "7", "--approve", "--body", "LGTM"]
1411 );
1412 }
1413
1414 #[tokio::test]
1415 async fn pr_comment_and_issue_create_return_urls() {
1416 let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
1417 let gh = GitHub::with_runner(&rec);
1418 assert_eq!(
1419 gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
1420 "https://gh/x"
1421 );
1422 assert_eq!(
1423 gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
1424 "https://gh/x"
1425 );
1426 let calls = rec.calls();
1427 assert_eq!(
1428 calls[0].args_str(),
1429 ["pr", "comment", "7", "--body", "hello"]
1430 );
1431 assert_eq!(
1432 calls[1].args_str(),
1433 ["issue", "create", "--title", "T", "--body", "B"]
1434 );
1435 }
1436
1437 #[tokio::test]
1441 async fn pr_edit_emits_only_provided_fields() {
1442 let rec = RecordingRunner::replying(Reply::ok(""));
1443 let gh = GitHub::with_runner(&rec);
1444
1445 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
1446 .await
1447 .expect("title-only edit");
1448 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
1449 .await
1450 .expect("body-only edit");
1451 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
1452 .await
1453 .expect("both-fields edit");
1454
1455 let calls = rec.calls();
1456 assert_eq!(
1457 calls[0].args_str(),
1458 ["pr", "edit", "7", "--title", "New title"]
1459 );
1460 assert_eq!(
1461 calls[1].args_str(),
1462 ["pr", "edit", "7", "--body", "New body"]
1463 );
1464 assert_eq!(
1465 calls[2].args_str(),
1466 ["pr", "edit", "7", "--title", "T", "--body", "B"]
1467 );
1468 }
1469
1470 #[tokio::test]
1475 async fn pr_edit_some_empty_string_clears_field() {
1476 let rec = RecordingRunner::replying(Reply::ok(""));
1477 let gh = GitHub::with_runner(&rec);
1478 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
1479 .await
1480 .expect("empty title");
1481 assert_eq!(
1482 rec.only_call().args_str(),
1483 ["pr", "edit", "7", "--title", ""]
1484 );
1485 }
1486
1487 #[tokio::test]
1488 async fn with_credentials_injects_gh_token_and_default_does_not() {
1489 let rec = RecordingRunner::replying(Reply::ok("[]"));
1492 let gh = GitHub::with_runner(&rec)
1493 .with_credentials(Arc::new(StaticCredential::token("tok-123")));
1494 gh.pr_list(Path::new("/r")).await.unwrap();
1495 let call = rec.only_call();
1496 let token = call
1497 .envs
1498 .iter()
1499 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1500 .and_then(|(_, v)| v.as_ref())
1501 .and_then(|v| v.to_str());
1502 assert_eq!(
1503 token,
1504 Some("tok-123"),
1505 "provider token injected as GH_TOKEN"
1506 );
1507 assert!(
1508 !call.args_str().iter().any(|a| a.contains("tok-123")),
1509 "secret must never appear in argv"
1510 );
1511
1512 let rec = RecordingRunner::replying(Reply::ok("[]"));
1514 let gh = GitHub::with_runner(&rec);
1515 gh.pr_list(Path::new("/r")).await.unwrap();
1516 assert!(
1517 !rec.only_call()
1518 .envs
1519 .iter()
1520 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
1521 "no provider → no token env (ambient gh auth)"
1522 );
1523 }
1524
1525 #[tokio::test]
1528 async fn with_token_convenience_injects_gh_token() {
1529 let rec = RecordingRunner::replying(Reply::ok("[]"));
1530 let gh = GitHub::with_runner(&rec).with_token("tok-conv");
1531 gh.pr_list(Path::new("/r")).await.unwrap();
1532 let call = rec.only_call();
1533 let token = call
1534 .envs
1535 .iter()
1536 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1537 .and_then(|(_, v)| v.as_ref())
1538 .and_then(|v| v.to_str());
1539 assert_eq!(token, Some("tok-conv"));
1540 }
1541
1542 #[tokio::test]
1546 async fn provider_returning_none_falls_back_to_ambient() {
1547 let rec = RecordingRunner::replying(Reply::ok("[]"));
1548 let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
1549 gh.pr_list(Path::new("/r")).await.unwrap();
1550 assert!(
1551 !rec.only_call()
1552 .envs
1553 .iter()
1554 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
1555 "Ok(None) provider injects no token (ambient)"
1556 );
1557 }
1558
1559 #[tokio::test]
1560 async fn injected_token_overrides_ambient_default_env() {
1561 let rec = RecordingRunner::replying(Reply::ok("[]"));
1564 let gh = GitHub::with_runner(&rec)
1565 .default_env("GH_TOKEN", "ambient-token")
1566 .with_credentials(Arc::new(StaticCredential::token("provider-token")));
1567 gh.pr_list(Path::new("/r")).await.unwrap();
1568 let call = rec.only_call();
1569 let winner = call
1570 .envs
1571 .iter()
1572 .rev()
1573 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1574 .and_then(|(_, v)| v.as_ref())
1575 .and_then(|v| v.to_str());
1576 assert_eq!(winner, Some("provider-token"), "provider token wins");
1577 }
1578
1579 #[tokio::test]
1580 async fn pr_feedback_requests_reviews_and_comments() {
1581 let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
1582 "body":"","submittedAt":""}],"comments":[]}"#;
1583 let rec =
1584 RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
1585 let gh = GitHub::with_runner(&rec);
1586 let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
1587 assert_eq!(feedback.reviews[0].author, "a");
1588 assert!(feedback.comments.is_empty());
1589 assert_eq!(
1590 rec.only_call().args_str(),
1591 ["pr", "view", "7", "--json", "reviews,comments"]
1592 );
1593 }
1594
1595 #[tokio::test]
1597 async fn run_list_appends_branch_only_when_some() {
1598 let rec = RecordingRunner::replying(Reply::ok("[]"));
1599 let gh = GitHub::with_runner(&rec);
1600 gh.run_list(Path::new("/r"), 5, None).await.expect("list");
1601 gh.run_list(Path::new("/r"), 5, Some("main".into()))
1602 .await
1603 .expect("list");
1604 let calls = rec.calls();
1605 assert_eq!(
1606 calls[0].args_str(),
1607 ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
1608 );
1609 assert_eq!(
1610 calls[1].args_str(),
1611 [
1612 "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
1613 ]
1614 );
1615 }
1616
1617 #[tokio::test]
1621 async fn run_watch_then_views_final_state() {
1622 let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
1623 "status":"completed","conclusion":"failure","workflowName":"CI",
1624 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
1625 let rec = RecordingRunner::new(
1626 ScriptedRunner::new()
1627 .on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
1628 .on(["gh", "run", "view"], Reply::ok(json)),
1629 );
1630 let gh = GitHub::with_runner(&rec);
1631 let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
1632 assert_eq!(run.conclusion, "failure");
1633 let calls = rec.calls();
1634 assert_eq!(calls.len(), 2);
1635 assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
1636 assert_eq!(
1637 calls[1].args_str(),
1638 ["run", "view", "42", "--json", RUN_FIELDS]
1639 );
1640 }
1641
1642 #[tokio::test]
1646 async fn run_watch_surfaces_timeout_and_watch_errors() {
1647 let rec = RecordingRunner::new(
1648 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
1649 );
1650 let gh = GitHub::with_runner(&rec);
1651 assert!(matches!(
1652 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1653 Error::Timeout { .. }
1654 ));
1655 assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
1656
1657 let gh = GitHub::with_runner(
1658 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
1659 );
1660 assert!(matches!(
1661 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1662 Error::Exit { .. }
1663 ));
1664 }
1665
1666 #[tokio::test(start_paused = true)]
1674 async fn run_watch_cancels_via_client_default_token() {
1675 use processkit::CancellationToken;
1676 let token = CancellationToken::new();
1677 let gh =
1678 GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
1679 .default_cancel_on(token.clone());
1680 let call = gh.run_watch(Path::new("."), 42);
1681 tokio::pin!(call);
1682 assert!(
1683 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
1684 .await
1685 .is_err(),
1686 "run_watch must park until the token fires"
1687 );
1688 token.cancel();
1689 match call.await {
1690 Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
1691 other => panic!("expected Error::Cancelled, got {other:?}"),
1692 }
1693 }
1694
1695 #[tokio::test]
1696 async fn release_view_requests_view_fields() {
1697 let json = r#"{"tagName":"v1","name":"","body":"notes","url":"u",
1698 "publishedAt":"p","isDraft":false,"isPrerelease":false}"#;
1699 let rec = RecordingRunner::new(
1700 ScriptedRunner::new().on(["gh", "release", "view"], Reply::ok(json)),
1701 );
1702 let gh = GitHub::with_runner(&rec);
1703 let release = gh
1704 .release_view(Path::new("."), "v1")
1705 .await
1706 .expect("release_view");
1707 assert_eq!(release.tag_name, "v1");
1708 assert_eq!(release.body, "notes");
1709 assert_eq!(
1710 rec.only_call().args_str(),
1711 ["release", "view", "v1", "--json", RELEASE_VIEW_FIELDS]
1712 );
1713 }
1714
1715 #[tokio::test]
1718 async fn repo_view_parses_scripted_json() {
1719 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
1720 let gh =
1721 GitHub::with_runner(ScriptedRunner::new().on(["gh", "repo", "view"], Reply::ok(json)));
1722 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
1723 assert_eq!(repo.owner, "o");
1724 assert_eq!(repo.default_branch, "main");
1725 assert!(!repo.is_private);
1726 }
1727
1728 #[cfg(feature = "mock")]
1729 #[tokio::test]
1730 async fn consumer_mocks_the_interface() {
1731 let mut mock = MockGitHubApi::new();
1732 mock.expect_auth_status().returning(|| Ok(true));
1733 assert!(mock.auth_status().await.unwrap());
1734 }
1735}
1736
1737#[doc = include_str!("../docs/github.md")]
1739#[allow(rustdoc::broken_intra_doc_links)]
1740pub mod guide {}