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    /// A single pull request by number (`gh pr view <n> --json …`).
47    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
48    /// Issues for `dir` (`gh issue list --json …`).
49    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
50    /// Open a pull request, returning its URL (`gh pr create`). `base` is owned
51    /// (`Option<String>`) to keep the trait `mockall`-friendly.
52    async fn pr_create(
53        &self,
54        dir: &Path,
55        title: &str,
56        body: &str,
57        base: Option<String>,
58    ) -> Result<String>;
59    /// Raw GitHub REST/GraphQL response body (`gh api <endpoint>`).
60    async fn api(&self, endpoint: &str) -> Result<String>;
61}
62
63processkit::cli_client!(
64    /// The real GitHub client. Generic over the [`ProcessRunner`] so tests can
65    /// inject a fake process executor; `GitHub::new()` uses the real job-backed
66    /// runner.
67    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        // `gh auth status` exits 0 when authenticated, non-zero when not — an
86        // exit-code answer. `code` reports the bool but still errors on a spawn
87        // failure or timeout (processkit surfaces a timeout as `Error::Timeout`),
88        // rather than silently reporting "not authenticated".
89        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    // Hermetic: real pr_list() arg-building + JSON deserialization against canned
168    // output — no `gh` binary or network needed, so this runs on CI.
169    #[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    // Hermetic: auth_status reflects the exit code without erroring.
180    #[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    // Regression guard for the timeout fix: a timed-out auth check must error,
191    // not silently report "not authenticated" (the old hand-rolled mapping bug).
192    // Relies on processkit surfacing a timed-out run as `Error::Timeout`.
193    #[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    // pr_create appends `--base <branch>` when given one, and returns the trimmed
203    // PR URL. The exact command (incl. --base) is the only scripted rule.
204    #[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    // Without a base, `pr_create` must omit `--base` entirely. RecordingRunner
220    // captures the exact invocation (and `&rec` plumbs through CliClient), so we
221    // can assert flag *absence* and the cwd — which prefix matching can't.
222    #[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    // repo_view builds the --json request and flattens gh's nested owner/branch
244    // objects into the public Repo.
245    #[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}