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 self.core.probe(self.core.command(["auth", "status"])).await
102 }
103
104 async fn repo_view(&self, dir: &Path) -> Result<Repo> {
105 self.core
106 .try_parse(
107 self.core
108 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
109 parse::parse_repo,
110 )
111 .await
112 }
113
114 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
115 self.core
116 .try_parse(
117 self.core
118 .command_in(dir, ["pr", "list", "--json", PR_FIELDS]),
119 parse::from_json,
120 )
121 .await
122 }
123
124 async fn pr_list_for_branch(
125 &self,
126 dir: &Path,
127 head: &str,
128 base: &str,
129 ) -> Result<Vec<PullRequest>> {
130 self.core
133 .try_parse(
134 self.core.command_in(
135 dir,
136 [
137 "pr", "list", "--head", head, "--base", base, "--state", "all", "--json",
138 PR_FIELDS,
139 ],
140 ),
141 parse::from_json,
142 )
143 .await
144 }
145
146 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
147 let n = number.to_string();
148 self.core
149 .try_parse(
150 self.core
151 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
152 parse::from_json,
153 )
154 .await
155 }
156
157 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
158 self.core
159 .try_parse(
160 self.core
161 .command_in(dir, ["issue", "list", "--json", "number,title,state"]),
162 parse::from_json,
163 )
164 .await
165 }
166
167 async fn pr_create(
168 &self,
169 dir: &Path,
170 title: &str,
171 body: &str,
172 head: Option<String>,
173 base: Option<String>,
174 ) -> Result<String> {
175 let mut args = vec!["pr", "create", "--title", title, "--body", body];
176 if let Some(head) = head.as_deref() {
177 args.push("--head");
178 args.push(head);
179 }
180 if let Some(base) = base.as_deref() {
181 args.push("--base");
182 args.push(base);
183 }
184 self.core.text(self.core.command_in(dir, args)).await
185 }
186
187 async fn api(&self, endpoint: &str) -> Result<String> {
188 self.core.text(self.core.command(["api", endpoint])).await
189 }
190}
191
192impl<R: ProcessRunner> GitHub<R> {
193 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
198 self.core.text(self.core.command(args)).await
199 }
200
201 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
204 self.core.capture(self.core.command(args)).await
205 }
206
207 pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
211 GitHubAt { gh: self, dir }
212 }
213}
214
215pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
219 gh: &'a GitHub<R>,
220 dir: &'a Path,
221}
222
223impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
227 fn clone(&self) -> Self {
228 *self
229 }
230}
231impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
232
233macro_rules! github_at_forwarders {
236 (
237 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
238 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
239 ) => {
240 impl<'a, R: ProcessRunner> GitHubAt<'a, R> {
241 $(
242 #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($bn), "`.")]
243 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
244 self.gh.$bn($($ba),*).await
245 }
246 )*
247 $(
248 #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
249 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
250 self.gh.$dn(self.dir, $($da),*).await
251 }
252 )*
253 }
254 };
255}
256
257github_at_forwarders! {
258 bare {
259 fn run(args: &[String]) -> Result<String>;
260 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
261 fn run_args(args: &[&str]) -> Result<String>;
262 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
263 fn version() -> Result<String>;
264 fn auth_status() -> Result<bool>;
265 fn api(endpoint: &str) -> Result<String>;
266 }
267 dir {
268 fn repo_view() -> Result<Repo>;
269 fn pr_list() -> Result<Vec<PullRequest>>;
270 fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
271 fn pr_view(number: u64) -> Result<PullRequest>;
272 fn issue_list() -> Result<Vec<Issue>>;
273 fn pr_create(title: &str, body: &str, head: Option<String>, base: Option<String>) -> Result<String>;
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use processkit::{RecordingRunner, Reply, ScriptedRunner};
281
282 #[test]
283 fn binary_name_is_gh() {
284 assert_eq!(BINARY, "gh");
285 }
286
287 #[allow(dead_code)]
289 fn bound_view_is_copy_for_default_runner() {
290 fn assert_copy<T: Copy>() {}
291 assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
292 }
293
294 #[tokio::test]
297 async fn bound_view_matches_dir_taking_calls() {
298 let dir = Path::new("/repo");
299 let rec = RecordingRunner::replying(Reply::ok("[]"));
300 let gh = GitHub::with_runner(&rec);
301
302 gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
303 gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
304
305 let calls = rec.calls();
306 assert_eq!(calls[0].args_str(), calls[1].args_str());
307 assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
308 }
309
310 #[tokio::test]
311 async fn run_args_forwards_str_slices() {
312 let gh = GitHub::with_runner(ScriptedRunner::new().on(["api", "user"], Reply::ok("ok\n")));
313 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
314 }
315
316 #[tokio::test]
319 async fn pr_list_parses_scripted_json() {
320 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
321 let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
322 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
323 assert_eq!(prs.len(), 1);
324 assert_eq!(prs[0].number, 7);
325 assert_eq!(prs[0].base_ref_name, "main");
326 }
327
328 #[tokio::test]
330 async fn auth_status_reads_exit_code() {
331 let yes = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
332 assert!(yes.auth_status().await.unwrap());
333 let no = GitHub::with_runner(
334 ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
335 );
336 assert!(!no.auth_status().await.unwrap());
337 }
338
339 #[tokio::test]
343 async fn auth_status_errors_on_timeout() {
344 let gh = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
345 assert!(matches!(
346 gh.auth_status().await.unwrap_err(),
347 Error::Timeout { .. }
348 ));
349 }
350
351 #[tokio::test]
354 async fn pr_create_appends_base_and_returns_url() {
355 let gh = GitHub::with_runner(ScriptedRunner::new().on(
356 [
357 "pr", "create", "--title", "T", "--body", "B", "--base", "main",
358 ],
359 Reply::ok("https://gh/pr/1\n"),
360 ));
361 let url = gh
362 .pr_create(Path::new("."), "T", "B", None, Some("main".to_string()))
363 .await
364 .expect("should build `pr create … --base main`");
365 assert_eq!(url, "https://gh/pr/1");
366 }
367
368 #[tokio::test]
371 async fn pr_create_appends_head_and_base() {
372 use processkit::RecordingRunner;
373 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
374 let gh = GitHub::with_runner(&rec);
375 gh.pr_create(
376 Path::new("/repo"),
377 "T",
378 "B",
379 Some("feat/x".to_string()),
380 Some("main".to_string()),
381 )
382 .await
383 .expect("pr_create");
384 assert_eq!(
385 rec.only_call().args_str(),
386 [
387 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
388 ]
389 );
390 }
391
392 #[tokio::test]
395 async fn pr_list_for_branch_filters_and_parses() {
396 use processkit::RecordingRunner;
397 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
398 let rec = RecordingRunner::replying(Reply::ok(json));
399 let gh = GitHub::with_runner(&rec);
400 let prs = gh
401 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
402 .await
403 .expect("pr_list_for_branch");
404 assert_eq!(prs.len(), 1);
405 assert_eq!(prs[0].title, "Merge feat");
406 assert_eq!(prs[0].url, "https://gh/pr/9");
407 assert_eq!(
408 rec.only_call().args_str(),
409 [
410 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--json",
411 PR_FIELDS
412 ]
413 );
414 }
415
416 #[tokio::test]
420 async fn pr_create_omits_base_when_none() {
421 use processkit::RecordingRunner;
422 use std::ffi::OsStr;
423 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
424 let gh = GitHub::with_runner(&rec);
425 let url = gh
426 .pr_create(Path::new("/repo"), "T", "B", None, None)
427 .await
428 .expect("pr_create");
429 assert_eq!(url, "https://gh/pr/2");
430
431 let call = rec.only_call();
432 assert_eq!(call.cwd.as_deref(), Some(OsStr::new("/repo")));
433 assert_eq!(
434 call.args_str(),
435 ["pr", "create", "--title", "T", "--body", "B"]
436 );
437 assert!(!call.has_flag("--base"), "no base was given");
438 assert!(!call.has_flag("--head"), "no head was given");
439 }
440
441 #[tokio::test]
444 async fn repo_view_parses_scripted_json() {
445 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
446 let gh = GitHub::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
447 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
448 assert_eq!(repo.owner, "o");
449 assert_eq!(repo.default_branch, "main");
450 assert!(!repo.is_private);
451 }
452
453 #[cfg(feature = "mock")]
454 #[tokio::test]
455 async fn consumer_mocks_the_interface() {
456 let mut mock = MockGitHubApi::new();
457 mock.expect_auth_status().returning(|| Ok(true));
458 assert!(mock.auth_status().await.unwrap());
459 }
460}