Skip to main content

vcs_github/
lib.rs

1//! `vcs-github` — automate GitHub from Rust through the `gh` CLI.
2//!
3//! Async, mockable, and structured-error: consumers depend on the [`GitHubApi`]
4//! trait and substitute a mock for the real [`GitHub`] client in tests. Commands
5//! run inside an OS job (via [`processkit`]) so a `gh` subprocess is never
6//! orphaned, and honour an optional [timeout](GitHub::default_timeout).
7//!
8//! Two test seams: enable the `mock` feature for a `mockall`-generated
9//! `MockGitHubApi`, or inject a fake runner with
10//! `GitHub::with_runner(`[`ScriptedRunner`](processkit::ScriptedRunner)`)`.
11
12use std::path::Path;
13
14use processkit::ProcessRunner;
15// Re-export the processkit types in this crate's public API (also brings
16// `Error`/`Result`/`ProcessResult` into scope here).
17pub use processkit::{Error, ProcessResult, Result};
18
19mod parse;
20pub use parse::{Issue, PullRequest, Repo};
21
22/// Name of the underlying CLI binary this crate drives.
23pub 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/// The GitHub operations this crate exposes — the interface consumers code
29/// against and mock in tests.
30#[cfg_attr(feature = "mock", mockall::automock)]
31#[async_trait::async_trait]
32pub trait GitHubApi: Send + Sync {
33    /// Run `gh <args>`, returning trimmed stdout (throws on a non-zero exit).
34    async fn run(&self, args: &[String]) -> Result<String>;
35    /// Like [`GitHubApi::run`] but never errors on a non-zero exit — returns the
36    /// captured [`ProcessResult`].
37    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
38    /// Installed GitHub CLI version (`gh --version`).
39    async fn version(&self) -> Result<String>;
40    /// Whether the user is authenticated (`gh auth status` exits zero).
41    async fn auth_status(&self) -> Result<bool>;
42    /// The repository for `dir` (`gh repo view --json …`).
43    async fn repo_view(&self, dir: &Path) -> Result<Repo>;
44    /// Pull requests for `dir` (`gh pr list --json …`).
45    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
46    /// Pull requests that merge `head` into `base`, in any state — open, closed,
47    /// or merged (`gh pr list --head <head> --base <base> --state all --json …`).
48    /// Each carries its title, URL, and `state`. Empty when none match.
49    async fn pr_list_for_branch(
50        &self,
51        dir: &Path,
52        head: &str,
53        base: &str,
54    ) -> Result<Vec<PullRequest>>;
55    /// A single pull request by number (`gh pr view <n> --json …`).
56    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
57    /// Issues for `dir` (`gh issue list --json …`).
58    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
59    /// Open a pull request, returning its URL (`gh pr create`). `head` (the
60    /// source branch; `None` = the current branch) and `base` (the target;
61    /// `None` = the repo default) are owned `Option<String>`s to keep the trait
62    /// `mockall`-friendly.
63    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    /// Raw GitHub REST/GraphQL response body (`gh api <endpoint>`).
72    async fn api(&self, endpoint: &str) -> Result<String>;
73}
74
75processkit::cli_client!(
76    /// The real GitHub client. Generic over the [`ProcessRunner`] so tests can
77    /// inject a fake process executor; `GitHub::new()` uses the real job-backed
78    /// runner.
79    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        // `gh auth status` exits 0 when authenticated, 1 when not — an exit-code
98        // answer. `probe` reads it as a bool but still errors on a spawn failure,
99        // timeout (`Error::Timeout`), or any unexpected exit code, rather than
100        // silently reporting "not authenticated".
101        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        // `--state all` so a closed/merged PR for this branch pair is reported
131        // too, not just open ones (gh's default); the caller filters on `state`.
132        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    /// Run `gh <args>` over string slices — `gh.run_args(&["pr", "list"])`
194    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
195    /// trait), so it can take `&[&str]`; forwards to the same path as
196    /// [`GitHubApi::run`].
197    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
198        self.core.text(self.core.command(args)).await
199    }
200
201    /// Like [`run_args`](GitHub::run_args) but never errors on a non-zero exit
202    /// (mirrors [`GitHubApi::run_raw`]).
203    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
204        self.core.capture(self.core.command(args)).await
205    }
206
207    /// Bind this client to `dir`, returning a [`GitHubAt`] handle whose `dir`-taking
208    /// methods omit that argument: `gh.at(dir).pr_list()` runs
209    /// [`pr_list`](GitHubApi::pr_list) against `dir`.
210    pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
211        GitHubAt { gh: self, dir }
212    }
213}
214
215/// A [`GitHub`] client with a working directory bound, so its repo-scoped methods
216/// drop the leading `dir` argument (`gh.at(dir).pr_list()`). Construct one with
217/// [`GitHub::at`].
218pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
219    gh: &'a GitHub<R>,
220    dir: &'a Path,
221}
222
223// Hand-written rather than derived: holding only references, the view is `Copy`
224// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
225// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the handle.
226impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
227    fn clone(&self) -> Self {
228        *self
229    }
230}
231impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
232
233/// Generate [`GitHubAt`] forwarders: `bare` methods forward verbatim, `dir`
234/// methods inject `self.dir` as the first argument.
235macro_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    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
288    #[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    // The bound view (`gh.at(dir)`) must produce byte-identical argv to the
295    // dir-taking call.
296    #[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    // Hermetic: real pr_list() arg-building + JSON deserialization against canned
317    // output — no `gh` binary or network needed, so this runs on CI.
318    #[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    // Hermetic: auth_status reflects the exit code without erroring.
329    #[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    // Regression guard for the timeout fix: a timed-out auth check must error,
340    // not silently report "not authenticated" (the old hand-rolled mapping bug).
341    // Relies on processkit surfacing a timed-out run as `Error::Timeout`.
342    #[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    // pr_create appends `--base <branch>` when given one, and returns the trimmed
352    // PR URL. The exact command (incl. --base) is the only scripted rule.
353    #[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    // With an explicit head, `pr_create` inserts `--head <branch>` before
369    // `--base` — so a PR can target an arbitrary source→target pair.
370    #[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    // pr_list_for_branch filters by head + base and parses the PR list (title +
393    // url available on each result).
394    #[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    // Without a base, `pr_create` must omit `--base` entirely. RecordingRunner
417    // captures the exact invocation (and `&rec` plumbs through CliClient), so we
418    // can assert flag *absence* and the cwd — which prefix matching can't.
419    #[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    // repo_view builds the --json request and flattens gh's nested owner/branch
442    // objects into the public Repo.
443    #[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}