1use std::path::Path;
13
14use processkit::ProcessRunner;
15pub use processkit::{Error, ProcessResult, Result};
18
19mod parse;
20pub use parse::{Issue, PullRequest, Repo};
21
22pub const BINARY: &str = "gh";
24
25const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
26const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
27
28#[cfg_attr(feature = "mock", mockall::automock)]
31#[async_trait::async_trait]
32pub trait GitHubApi: Send + Sync {
33 async fn run(&self, args: &[String]) -> Result<String>;
35 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
38 async fn version(&self) -> Result<String>;
40 async fn auth_status(&self) -> Result<bool>;
42 async fn repo_view(&self, dir: &Path) -> Result<Repo>;
44 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
46 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
48 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
50 async fn pr_create(
53 &self,
54 dir: &Path,
55 title: &str,
56 body: &str,
57 base: Option<String>,
58 ) -> Result<String>;
59 async fn api(&self, endpoint: &str) -> Result<String>;
61}
62
63processkit::cli_client!(
64 pub struct GitHub => BINARY
68);
69
70#[async_trait::async_trait]
71impl<R: ProcessRunner> GitHubApi for GitHub<R> {
72 async fn run(&self, args: &[String]) -> Result<String> {
73 self.core.text(self.core.command(args)).await
74 }
75
76 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
77 self.core.capture(self.core.command(args)).await
78 }
79
80 async fn version(&self) -> Result<String> {
81 self.core.text(self.core.command(["--version"])).await
82 }
83
84 async fn auth_status(&self) -> Result<bool> {
85 Ok(self
90 .core
91 .code(self.core.command(["auth", "status"]))
92 .await?
93 == 0)
94 }
95
96 async fn repo_view(&self, dir: &Path) -> Result<Repo> {
97 self.core
98 .try_parse(
99 self.core
100 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
101 parse::parse_repo,
102 )
103 .await
104 }
105
106 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
107 self.core
108 .try_parse(
109 self.core
110 .command_in(dir, ["pr", "list", "--json", PR_FIELDS]),
111 parse::from_json,
112 )
113 .await
114 }
115
116 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
117 let n = number.to_string();
118 self.core
119 .try_parse(
120 self.core
121 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
122 parse::from_json,
123 )
124 .await
125 }
126
127 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
128 self.core
129 .try_parse(
130 self.core
131 .command_in(dir, ["issue", "list", "--json", "number,title,state"]),
132 parse::from_json,
133 )
134 .await
135 }
136
137 async fn pr_create(
138 &self,
139 dir: &Path,
140 title: &str,
141 body: &str,
142 base: Option<String>,
143 ) -> Result<String> {
144 let mut args = vec!["pr", "create", "--title", title, "--body", body];
145 if let Some(base) = base.as_deref() {
146 args.push("--base");
147 args.push(base);
148 }
149 self.core.text(self.core.command_in(dir, args)).await
150 }
151
152 async fn api(&self, endpoint: &str) -> Result<String> {
153 self.core.text(self.core.command(["api", endpoint])).await
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use processkit::{Reply, ScriptedRunner};
161
162 #[test]
163 fn binary_name_is_gh() {
164 assert_eq!(BINARY, "gh");
165 }
166
167 #[tokio::test]
170 async fn pr_list_parses_scripted_json() {
171 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
172 let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
173 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
174 assert_eq!(prs.len(), 1);
175 assert_eq!(prs[0].number, 7);
176 assert_eq!(prs[0].base_ref_name, "main");
177 }
178
179 #[tokio::test]
181 async fn auth_status_reads_exit_code() {
182 let yes = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
183 assert!(yes.auth_status().await.unwrap());
184 let no = GitHub::with_runner(
185 ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
186 );
187 assert!(!no.auth_status().await.unwrap());
188 }
189
190 #[tokio::test]
194 async fn auth_status_errors_on_timeout() {
195 let gh = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
196 assert!(matches!(
197 gh.auth_status().await.unwrap_err(),
198 Error::Timeout { .. }
199 ));
200 }
201
202 #[tokio::test]
205 async fn pr_create_appends_base_and_returns_url() {
206 let gh = GitHub::with_runner(ScriptedRunner::new().on(
207 [
208 "pr", "create", "--title", "T", "--body", "B", "--base", "main",
209 ],
210 Reply::ok("https://gh/pr/1\n"),
211 ));
212 let url = gh
213 .pr_create(Path::new("."), "T", "B", Some("main".to_string()))
214 .await
215 .expect("should build `pr create … --base main`");
216 assert_eq!(url, "https://gh/pr/1");
217 }
218
219 #[tokio::test]
223 async fn pr_create_omits_base_when_none() {
224 use processkit::RecordingRunner;
225 use std::ffi::OsStr;
226 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
227 let gh = GitHub::with_runner(&rec);
228 let url = gh
229 .pr_create(Path::new("/repo"), "T", "B", None)
230 .await
231 .expect("pr_create");
232 assert_eq!(url, "https://gh/pr/2");
233
234 let call = rec.only_call();
235 assert_eq!(call.cwd.as_deref(), Some(OsStr::new("/repo")));
236 assert_eq!(
237 call.args_str(),
238 ["pr", "create", "--title", "T", "--body", "B"]
239 );
240 assert!(!call.has_flag("--base"), "no base was given");
241 }
242
243 #[tokio::test]
246 async fn repo_view_parses_scripted_json() {
247 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
248 let gh = GitHub::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
249 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
250 assert_eq!(repo.owner, "o");
251 assert_eq!(repo.default_branch, "main");
252 assert!(!repo.is_private);
253 }
254
255 #[cfg(feature = "mock")]
256 #[tokio::test]
257 async fn consumer_mocks_the_interface() {
258 let mut mock = MockGitHubApi::new();
259 mock.expect_auth_status().returning(|| Ok(true));
260 assert!(mock.auth_status().await.unwrap());
261 }
262}