use std::path::Path;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{Issue, PullRequest, Repo};
pub const BINARY: &str = "gh";
const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait GitHubApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn auth_status(&self) -> Result<bool>;
async fn repo_view(&self, dir: &Path) -> Result<Repo>;
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
async fn pr_create(
&self,
dir: &Path,
title: &str,
body: &str,
base: Option<String>,
) -> Result<String>;
async fn api(&self, endpoint: &str) -> Result<String>;
}
processkit::cli_client!(
pub struct GitHub => BINARY
);
#[async_trait::async_trait]
impl<R: ProcessRunner> GitHubApi for GitHub<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.text(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.capture(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.text(self.core.command(["--version"])).await
}
async fn auth_status(&self) -> Result<bool> {
Ok(self
.core
.code(self.core.command(["auth", "status"]))
.await?
== 0)
}
async fn repo_view(&self, dir: &Path) -> Result<Repo> {
self.core
.try_parse(
self.core
.command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
parse::parse_repo,
)
.await
}
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
self.core
.try_parse(
self.core
.command_in(dir, ["pr", "list", "--json", PR_FIELDS]),
parse::from_json,
)
.await
}
async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
let n = number.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
parse::from_json,
)
.await
}
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
self.core
.try_parse(
self.core
.command_in(dir, ["issue", "list", "--json", "number,title,state"]),
parse::from_json,
)
.await
}
async fn pr_create(
&self,
dir: &Path,
title: &str,
body: &str,
base: Option<String>,
) -> Result<String> {
let mut args = vec!["pr", "create", "--title", title, "--body", body];
if let Some(base) = base.as_deref() {
args.push("--base");
args.push(base);
}
self.core.text(self.core.command_in(dir, args)).await
}
async fn api(&self, endpoint: &str) -> Result<String> {
self.core.text(self.core.command(["api", endpoint])).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{Reply, ScriptedRunner};
#[test]
fn binary_name_is_gh() {
assert_eq!(BINARY, "gh");
}
#[tokio::test]
async fn pr_list_parses_scripted_json() {
let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
let gh = GitHub::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].number, 7);
assert_eq!(prs[0].base_ref_name, "main");
}
#[tokio::test]
async fn auth_status_reads_exit_code() {
let yes = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
assert!(yes.auth_status().await.unwrap());
let no = GitHub::with_runner(
ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
);
assert!(!no.auth_status().await.unwrap());
}
#[tokio::test]
async fn auth_status_errors_on_timeout() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
assert!(matches!(
gh.auth_status().await.unwrap_err(),
Error::Timeout { .. }
));
}
#[tokio::test]
async fn pr_create_appends_base_and_returns_url() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(
[
"pr", "create", "--title", "T", "--body", "B", "--base", "main",
],
Reply::ok("https://gh/pr/1\n"),
));
let url = gh
.pr_create(Path::new("."), "T", "B", Some("main".to_string()))
.await
.expect("should build `pr create … --base main`");
assert_eq!(url, "https://gh/pr/1");
}
#[tokio::test]
async fn pr_create_omits_base_when_none() {
use processkit::RecordingRunner;
use std::ffi::OsStr;
let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
let gh = GitHub::with_runner(&rec);
let url = gh
.pr_create(Path::new("/repo"), "T", "B", None)
.await
.expect("pr_create");
assert_eq!(url, "https://gh/pr/2");
let call = rec.only_call();
assert_eq!(call.cwd.as_deref(), Some(OsStr::new("/repo")));
assert_eq!(
call.args_str(),
["pr", "create", "--title", "T", "--body", "B"]
);
assert!(!call.has_flag("--base"), "no base was given");
}
#[tokio::test]
async fn repo_view_parses_scripted_json() {
let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
let gh = GitHub::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
assert_eq!(repo.owner, "o");
assert_eq!(repo.default_branch, "main");
assert!(!repo.is_private);
}
#[cfg(feature = "mock")]
#[tokio::test]
async fn consumer_mocks_the_interface() {
let mut mock = MockGitHubApi::new();
mock.expect_auth_status().returning(|| Ok(true));
assert!(mock.auth_status().await.unwrap());
}
}