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_list_for_branch(
50 &self,
51 dir: &Path,
52 head: &str,
53 base: &str,
54 ) -> Result<Vec<PullRequest>>;
55 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
57 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
59 async fn pr_create(
64 &self,
65 dir: &Path,
66 title: &str,
67 body: &str,
68 head: Option<String>,
69 base: Option<String>,
70 ) -> Result<String>;
71 async fn api(&self, endpoint: &str) -> Result<String>;
73}
74
75processkit::cli_client!(
76 pub struct GitHub => BINARY
80);
81
82#[async_trait::async_trait]
83impl<R: ProcessRunner> GitHubApi for GitHub<R> {
84 async fn run(&self, args: &[String]) -> Result<String> {
85 self.core.text(self.core.command(args)).await
86 }
87
88 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
89 self.core.capture(self.core.command(args)).await
90 }
91
92 async fn version(&self) -> Result<String> {
93 self.core.text(self.core.command(["--version"])).await
94 }
95
96 async fn auth_status(&self) -> Result<bool> {
97 Ok(self
102 .core
103 .code(self.core.command(["auth", "status"]))
104 .await?
105 == 0)
106 }
107
108 async fn repo_view(&self, dir: &Path) -> Result<Repo> {
109 self.core
110 .try_parse(
111 self.core
112 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
113 parse::parse_repo,
114 )
115 .await
116 }
117
118 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
119 self.core
120 .try_parse(
121 self.core
122 .command_in(dir, ["pr", "list", "--json", PR_FIELDS]),
123 parse::from_json,
124 )
125 .await
126 }
127
128 async fn pr_list_for_branch(
129 &self,
130 dir: &Path,
131 head: &str,
132 base: &str,
133 ) -> Result<Vec<PullRequest>> {
134 self.core
137 .try_parse(
138 self.core.command_in(
139 dir,
140 [
141 "pr", "list", "--head", head, "--base", base, "--state", "all", "--json",
142 PR_FIELDS,
143 ],
144 ),
145 parse::from_json,
146 )
147 .await
148 }
149
150 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
151 let n = number.to_string();
152 self.core
153 .try_parse(
154 self.core
155 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
156 parse::from_json,
157 )
158 .await
159 }
160
161 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
162 self.core
163 .try_parse(
164 self.core
165 .command_in(dir, ["issue", "list", "--json", "number,title,state"]),
166 parse::from_json,
167 )
168 .await
169 }
170
171 async fn pr_create(
172 &self,
173 dir: &Path,
174 title: &str,
175 body: &str,
176 head: Option<String>,
177 base: Option<String>,
178 ) -> Result<String> {
179 let mut args = vec!["pr", "create", "--title", title, "--body", body];
180 if let Some(head) = head.as_deref() {
181 args.push("--head");
182 args.push(head);
183 }
184 if let Some(base) = base.as_deref() {
185 args.push("--base");
186 args.push(base);
187 }
188 self.core.text(self.core.command_in(dir, args)).await
189 }
190
191 async fn api(&self, endpoint: &str) -> Result<String> {
192 self.core.text(self.core.command(["api", endpoint])).await
193 }
194}
195
196impl<R: ProcessRunner> GitHub<R> {
197 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
202 self.core.text(self.core.command(args)).await
203 }
204
205 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
208 self.core.capture(self.core.command(args)).await
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use processkit::{Reply, ScriptedRunner};
216
217 #[test]
218 fn binary_name_is_gh() {
219 assert_eq!(BINARY, "gh");
220 }
221
222 #[tokio::test]
223 async fn run_args_forwards_str_slices() {
224 let gh = GitHub::with_runner(ScriptedRunner::new().on(["api", "user"], Reply::ok("ok\n")));
225 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
226 }
227
228 #[tokio::test]
231 async fn pr_list_parses_scripted_json() {
232 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
233 let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
234 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
235 assert_eq!(prs.len(), 1);
236 assert_eq!(prs[0].number, 7);
237 assert_eq!(prs[0].base_ref_name, "main");
238 }
239
240 #[tokio::test]
242 async fn auth_status_reads_exit_code() {
243 let yes = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
244 assert!(yes.auth_status().await.unwrap());
245 let no = GitHub::with_runner(
246 ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
247 );
248 assert!(!no.auth_status().await.unwrap());
249 }
250
251 #[tokio::test]
255 async fn auth_status_errors_on_timeout() {
256 let gh = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
257 assert!(matches!(
258 gh.auth_status().await.unwrap_err(),
259 Error::Timeout { .. }
260 ));
261 }
262
263 #[tokio::test]
266 async fn pr_create_appends_base_and_returns_url() {
267 let gh = GitHub::with_runner(ScriptedRunner::new().on(
268 [
269 "pr", "create", "--title", "T", "--body", "B", "--base", "main",
270 ],
271 Reply::ok("https://gh/pr/1\n"),
272 ));
273 let url = gh
274 .pr_create(Path::new("."), "T", "B", None, Some("main".to_string()))
275 .await
276 .expect("should build `pr create … --base main`");
277 assert_eq!(url, "https://gh/pr/1");
278 }
279
280 #[tokio::test]
283 async fn pr_create_appends_head_and_base() {
284 use processkit::RecordingRunner;
285 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
286 let gh = GitHub::with_runner(&rec);
287 gh.pr_create(
288 Path::new("/repo"),
289 "T",
290 "B",
291 Some("feat/x".to_string()),
292 Some("main".to_string()),
293 )
294 .await
295 .expect("pr_create");
296 assert_eq!(
297 rec.only_call().args_str(),
298 [
299 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
300 ]
301 );
302 }
303
304 #[tokio::test]
307 async fn pr_list_for_branch_filters_and_parses() {
308 use processkit::RecordingRunner;
309 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
310 let rec = RecordingRunner::replying(Reply::ok(json));
311 let gh = GitHub::with_runner(&rec);
312 let prs = gh
313 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
314 .await
315 .expect("pr_list_for_branch");
316 assert_eq!(prs.len(), 1);
317 assert_eq!(prs[0].title, "Merge feat");
318 assert_eq!(prs[0].url, "https://gh/pr/9");
319 assert_eq!(
320 rec.only_call().args_str(),
321 [
322 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--json",
323 PR_FIELDS
324 ]
325 );
326 }
327
328 #[tokio::test]
332 async fn pr_create_omits_base_when_none() {
333 use processkit::RecordingRunner;
334 use std::ffi::OsStr;
335 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
336 let gh = GitHub::with_runner(&rec);
337 let url = gh
338 .pr_create(Path::new("/repo"), "T", "B", None, None)
339 .await
340 .expect("pr_create");
341 assert_eq!(url, "https://gh/pr/2");
342
343 let call = rec.only_call();
344 assert_eq!(call.cwd.as_deref(), Some(OsStr::new("/repo")));
345 assert_eq!(
346 call.args_str(),
347 ["pr", "create", "--title", "T", "--body", "B"]
348 );
349 assert!(!call.has_flag("--base"), "no base was given");
350 assert!(!call.has_flag("--head"), "no head was given");
351 }
352
353 #[tokio::test]
356 async fn repo_view_parses_scripted_json() {
357 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
358 let gh = GitHub::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
359 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
360 assert_eq!(repo.owner, "o");
361 assert_eq!(repo.default_branch, "main");
362 assert!(!repo.is_private);
363 }
364
365 #[cfg(feature = "mock")]
366 #[tokio::test]
367 async fn consumer_mocks_the_interface() {
368 let mut mock = MockGitHubApi::new();
369 mock.expect_auth_status().returning(|| Ok(true));
370 assert!(mock.auth_status().await.unwrap());
371 }
372}