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, non-zero when not — an
98        // exit-code answer. `code` reports the bool but still errors on a spawn
99        // failure or timeout (processkit surfaces a timeout as `Error::Timeout`),
100        // rather than silently reporting "not authenticated".
101        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        // `--state all` so a closed/merged PR for this branch pair is reported
135        // too, not just open ones (gh's default); the caller filters on `state`.
136        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    /// Run `gh <args>` over string slices — `gh.run_args(&["pr", "list"])`
198    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
199    /// trait), so it can take `&[&str]`; forwards to the same path as
200    /// [`GitHubApi::run`].
201    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
202        self.core.text(self.core.command(args)).await
203    }
204
205    /// Like [`run_args`](GitHub::run_args) but never errors on a non-zero exit
206    /// (mirrors [`GitHubApi::run_raw`]).
207    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    // Hermetic: real pr_list() arg-building + JSON deserialization against canned
229    // output — no `gh` binary or network needed, so this runs on CI.
230    #[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    // Hermetic: auth_status reflects the exit code without erroring.
241    #[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    // Regression guard for the timeout fix: a timed-out auth check must error,
252    // not silently report "not authenticated" (the old hand-rolled mapping bug).
253    // Relies on processkit surfacing a timed-out run as `Error::Timeout`.
254    #[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    // pr_create appends `--base <branch>` when given one, and returns the trimmed
264    // PR URL. The exact command (incl. --base) is the only scripted rule.
265    #[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    // With an explicit head, `pr_create` inserts `--head <branch>` before
281    // `--base` — so a PR can target an arbitrary source→target pair.
282    #[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    // pr_list_for_branch filters by head + base and parses the PR list (title +
305    // url available on each result).
306    #[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    // Without a base, `pr_create` must omit `--base` entirely. RecordingRunner
329    // captures the exact invocation (and `&rec` plumbs through CliClient), so we
330    // can assert flag *absence* and the cwd — which prefix matching can't.
331    #[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    // repo_view builds the --json request and flattens gh's nested owner/branch
354    // objects into the public Repo.
355    #[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}