#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::Path;
use std::sync::Arc;
pub use vcs_cli_support::{
Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
OutputBudget, Secret, StaticCredential, provider_fn,
};
pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
pub use processkit::CancellationToken;
mod parse;
pub use parse::{
CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, RepoView, Review,
WorkflowRun,
};
pub use vcs_diff::{ChangeKind, DiffLine, FileDiff, Hunk};
pub use vcs_diff::Version as GitHubVersion;
pub const BINARY: &str = "gh";
const PR_FIELDS: &str = "number,title,state,isDraft,headRefName,baseRefName,url,labels,assignees";
const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
const ISSUE_LIST_FIELDS: &str = "number,title,state,body,url,labels,assignees";
const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url,labels,assignees";
const RUN_FIELDS: &str =
"databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease";
fn reject_flag_like(what: &str, value: &str) -> Result<()> {
vcs_cli_support::reject_flag_like(BINARY, what, value)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GitHubHost {
host: String,
enterprise: bool,
}
impl GitHubHost {
pub const SAAS_HOST: &'static str = "github.com";
#[must_use]
pub fn github_com() -> Self {
Self {
host: Self::SAAS_HOST.to_string(),
enterprise: false,
}
}
pub fn new(host: impl AsRef<str>) -> Result<Self> {
let host = validate_host(host.as_ref())?;
let enterprise = host != Self::SAAS_HOST;
Ok(Self { host, enterprise })
}
pub fn from_remote_url(url: &str) -> Result<Self> {
match host_from_remote_url(url) {
Some(host) => Self::new(host),
None => Err(invalid_host_error(
url,
"no GitHub host could be determined from the remote URL",
)),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.host
}
#[must_use]
pub fn is_enterprise(&self) -> bool {
self.enterprise
}
#[must_use]
pub fn is_github_com(&self) -> bool {
!self.enterprise
}
fn token_env_var(&self) -> &'static str {
if self.enterprise {
"GH_ENTERPRISE_TOKEN"
} else {
"GH_TOKEN"
}
}
}
fn validate_host(host: &str) -> Result<String> {
let trimmed = host.trim();
let well_formed = !trimmed.is_empty()
&& !trimmed.starts_with('-')
&& !trimmed.starts_with('.')
&& !trimmed.ends_with('.')
&& trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
if !well_formed {
return Err(invalid_host_error(host, "not a valid GitHub hostname"));
}
Ok(trimmed.to_ascii_lowercase())
}
fn invalid_host_error(value: &str, reason: &str) -> Error {
Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("GitHub host {value:?}: {reason}"),
),
)
}
fn host_from_remote_url(url: &str) -> Option<String> {
let url = url.trim();
if url.is_empty() {
return None;
}
if let Some((_scheme, rest)) = url.split_once("://") {
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
return strip_port(host_port);
}
if let Some((authority, _path)) = url.split_once(':') {
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
if host.contains('.') && !host.contains('/') && !host.contains('\\') {
return Some(host.to_string());
}
}
None
}
fn strip_port(host_port: &str) -> Option<String> {
if host_port.is_empty() || host_port.starts_with('[') {
return None;
}
Some(
host_port
.split_once(':')
.map_or(host_port, |(h, _)| h)
.to_string(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MergeStrategy {
Merge,
Squash,
Rebase,
}
impl MergeStrategy {
fn flag(self) -> &'static str {
match self {
MergeStrategy::Merge => "--merge",
MergeStrategy::Squash => "--squash",
MergeStrategy::Rebase => "--rebase",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PrMerge {
pub strategy: MergeStrategy,
pub auto: bool,
pub delete_branch: bool,
}
impl PrMerge {
pub fn merge() -> Self {
Self::with(MergeStrategy::Merge)
}
pub fn squash() -> Self {
Self::with(MergeStrategy::Squash)
}
pub fn rebase() -> Self {
Self::with(MergeStrategy::Rebase)
}
fn with(strategy: MergeStrategy) -> Self {
Self {
strategy,
auto: false,
delete_branch: false,
}
}
pub fn auto(mut self) -> Self {
self.auto = true;
self
}
pub fn delete_branch(mut self) -> Self {
self.delete_branch = true;
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrClose {
pub delete_branch: bool,
}
impl PrClose {
pub fn new() -> Self {
Self::default()
}
pub fn delete_branch(mut self) -> Self {
self.delete_branch = true;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PrCreate {
pub title: String,
pub body: String,
pub head: Option<String>,
pub base: Option<String>,
}
impl PrCreate {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
title: title.into(),
body: body.into(),
head: None,
base: None,
}
}
pub fn head(mut self, head: impl Into<String>) -> Self {
self.head = Some(head.into());
self
}
pub fn base(mut self, base: impl Into<String>) -> Self {
self.base = Some(base.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrEdit {
pub title: Option<String>,
pub body: Option<String>,
}
impl PrEdit {
pub fn new() -> Self {
Self {
title: None,
body: None,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
}
impl Default for PrEdit {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReviewKind {
Approve,
RequestChanges,
Comment,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ReviewAction {
kind: ReviewKind,
body: Option<String>,
}
impl ReviewAction {
pub fn approve() -> Self {
Self {
kind: ReviewKind::Approve,
body: None,
}
}
pub fn request_changes(body: impl Into<String>) -> Self {
Self {
kind: ReviewKind::RequestChanges,
body: Some(body.into()),
}
}
pub fn comment(body: impl Into<String>) -> Self {
Self {
kind: ReviewKind::Comment,
body: Some(body.into()),
}
}
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn kind(&self) -> ReviewKind {
self.kind
}
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct GitHubCapabilities {
pub version: GitHubVersion,
}
const MIN_SUPPORTED: GitHubVersion = GitHubVersion {
major: 2,
minor: 0,
patch: 0,
};
impl GitHubCapabilities {
pub fn is_supported(&self) -> bool {
self.version >= MIN_SUPPORTED
}
pub fn ensure_supported(&self) -> Result<()> {
if self.is_supported() {
return Ok(());
}
Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"vcs-github requires gh >= {MIN_SUPPORTED}, found {}",
self.version
),
),
))
}
}
#[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 capabilities(&self) -> Result<GitHubCapabilities>;
async fn auth_status(&self) -> Result<bool>;
#[allow(unused_variables)]
async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
Err(Error::Unsupported {
operation: "auth_status_for".into(),
})
}
async fn repo_view(&self, dir: &Path) -> Result<RepoView>;
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
async fn pr_list_for_branch(
&self,
dir: &Path,
head: &str,
base: &str,
) -> 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, spec: PrCreate) -> Result<String>;
async fn api(&self, dir: &Path, endpoint: &str) -> Result<String>;
async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()>;
async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()>;
#[allow(unused_variables)]
async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
Err(Error::Unsupported {
operation: "pr_checkout".into(),
})
}
async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
#[allow(unused_variables)]
async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
Err(Error::Unsupported {
operation: "pr_edit".into(),
})
}
async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>>;
async fn run_list(
&self,
dir: &Path,
limit: u64,
branch: Option<String>,
) -> Result<Vec<WorkflowRun>>;
async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
}
vcs_cli_support::managed_client! {
pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
}
impl<R: ProcessRunner> GitHub<R> {
#[must_use]
pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
self.core = self.core.with_credentials(provider);
self
}
#[must_use]
pub fn with_token(self, token: impl Into<Secret>) -> Self {
self.with_credentials(Arc::new(StaticCredential::token(token)))
}
#[must_use]
pub fn with_env_token(self, var: impl Into<String>) -> Self {
self.with_credentials(Arc::new(EnvToken::new(var)))
}
#[must_use]
pub fn with_host(mut self, host: GitHubHost) -> Self {
self.core = self
.core
.with_token_env(CredentialService::GitHub, host.token_env_var())
.with_expected_host(host.as_str())
.default_env("GH_HOST", host.as_str());
self
}
}
#[async_trait::async_trait]
impl<R: ProcessRunner> GitHubApi for GitHub<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(args).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output_string(args).await
}
async fn version(&self) -> Result<String> {
self.core.run(["--version"]).await
}
async fn capabilities(&self) -> Result<GitHubCapabilities> {
let raw = self.version().await?;
let version = parse::parse_gh_version(&raw).ok_or_else(|| {
Error::parse(
BINARY,
format!("unrecognisable `gh --version` output: {raw:?}"),
)
})?;
Ok(GitHubCapabilities { version })
}
async fn auth_status(&self) -> Result<bool> {
Ok(self.core.exit_code(["auth", "status"]).await? == 0)
}
async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
Ok(self
.core
.exit_code(["auth", "status", "--hostname", host.as_str()])
.await?
== 0)
}
async fn repo_view(&self, dir: &Path) -> Result<RepoView> {
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", "--limit", "100", "--json", PR_FIELDS]),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
async fn pr_list_for_branch(
&self,
dir: &Path,
head: &str,
base: &str,
) -> Result<Vec<PullRequest>> {
self.core
.try_parse(
self.core.command_in(
dir,
[
"pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
"100", "--json", PR_FIELDS,
],
),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.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]),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
self.core
.try_parse(
self.core.command_in(
dir,
[
"issue",
"list",
"--limit",
"100",
"--json",
ISSUE_LIST_FIELDS,
],
),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
let mut args = vec![
"pr",
"create",
"--title",
spec.title.as_str(),
"--body",
spec.body.as_str(),
];
if let Some(head) = spec.head.as_deref() {
args.push("--head");
args.push(head);
}
if let Some(base) = spec.base.as_deref() {
args.push("--base");
args.push(base);
}
self.core.run(self.core.command_in(dir, args)).await
}
async fn api(&self, dir: &Path, endpoint: &str) -> Result<String> {
reject_flag_like("endpoint", endpoint)?;
self.core
.run(self.core.command_in(dir, ["api", endpoint]))
.await
}
async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
let n = number.to_string();
let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
if merge.auto {
args.push("--auto");
}
if merge.delete_branch {
args.push("--delete-branch");
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()> {
let n = number.to_string();
self.core
.run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
.await
}
async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()> {
let n = number.to_string();
let mut args = vec!["pr", "close", n.as_str()];
if spec.delete_branch {
args.push("--delete-branch");
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
let n = number.to_string();
self.core
.run_unit(self.core.command_in(dir, ["pr", "checkout", n.as_str()]))
.await
}
async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
let n = number.to_string();
let res = self
.core
.output_string(
self.core
.command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
)
.await?;
match res.code() {
Some(0) => vcs_cli_support::json::from_json(BINARY, res.stdout()),
Some(1 | 8) if !res.stdout().trim().is_empty() => {
vcs_cli_support::json::from_json(BINARY, res.stdout())
}
_ if res
.stderr()
.to_ascii_lowercase()
.contains("no checks reported") =>
{
Ok(Vec::new())
}
_ => {
let _ = res.ensure_success()?;
Ok(Vec::new()) }
}
}
async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
let n = number.to_string();
let mut args = vec!["pr", "review", n.as_str()];
args.push(match action.kind() {
ReviewKind::Approve => "--approve",
ReviewKind::RequestChanges => "--request-changes",
ReviewKind::Comment => "--comment",
});
if let Some(body) = action.body() {
args.push("--body");
args.push(body);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
let n = number.to_string();
self.core
.run(
self.core
.command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
)
.await
}
async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
let n = number.to_string();
let mut args = vec!["pr", "edit", n.as_str()];
if let Some(title) = edit.title.as_deref() {
args.push("--title");
args.push(title);
}
if let Some(body) = edit.body.as_deref() {
args.push("--body");
args.push(body);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
let n = number.to_string();
self.core
.try_parse(
self.core.command_in(
dir,
["pr", "view", n.as_str(), "--json", "reviews,comments"],
),
parse::parse_feedback,
)
.await
}
async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>> {
self.pr_diff_within(dir, number, self.core.output_budget())
.await
}
async fn run_list(
&self,
dir: &Path,
limit: u64,
branch: Option<String>,
) -> Result<Vec<WorkflowRun>> {
let limit = limit.to_string();
let mut args = vec!["run", "list", "--limit", limit.as_str()];
if let Some(branch) = branch.as_deref() {
args.push("--branch");
args.push(branch);
}
args.extend(["--json", RUN_FIELDS]);
self.core
.try_parse(self.core.command_in(dir, args), |s| {
vcs_cli_support::json::from_json(BINARY, s)
})
.await
}
async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
let id = id.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
let id_str = id.to_string();
let watch_budget = OutputBudget::bytes(256 * 1024).with_max_lines(256);
let cmd = self
.core
.command_in(dir, ["run", "watch", id_str.as_str()])
.output_buffer(
watch_budget
.diagnostic_policy()
.expect("a byte/line budget yields a diagnostic policy"),
);
let _ = self.core.output_string(cmd).await?.ensure_success()?;
self.run_view(dir, id).await
}
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
self.core
.run(
self.core
.command_in(dir, ["issue", "create", "--title", title, "--body", body]),
)
.await
}
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
let n = number.to_string();
self.core
.try_parse(
self.core.command_in(
dir,
["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
self.core
.try_parse(
self.core.command_in(
dir,
[
"release",
"list",
"--limit",
"100",
"--json",
RELEASE_LIST_FIELDS,
],
),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
reject_flag_like("tag", tag)?;
self.core
.try_parse(
self.core
.command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
|s| vcs_cli_support::json::from_json(BINARY, s),
)
.await
}
}
impl<R: ProcessRunner> GitHub<R> {
pub async fn pr_diff_within(
&self,
dir: &Path,
number: u64,
budget: OutputBudget,
) -> Result<Vec<FileDiff>> {
let n = number.to_string();
let text = self
.core
.run_untrimmed_within(
self.core
.command_in(dir, ["pr", "diff", n.as_str(), "--color", "never"]),
budget,
)
.await?;
Ok(vcs_diff::parse_diff(&text))
}
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(args).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output_string(args).await
}
pub async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
pub async fn run_raw_args_in(
&self,
dir: &Path,
args: &[&str],
) -> Result<ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
GitHubAt { gh: self, dir }
}
}
pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
gh: &'a GitHub<R>,
dir: &'a Path,
}
impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
fn clone(&self) -> Self {
*self
}
}
impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
vcs_cli_support::at_forwarders! {
GitHubAt, gh, "GitHub",
bare {
fn version() -> Result<String>;
fn capabilities() -> Result<GitHubCapabilities>;
fn auth_status() -> Result<bool>;
fn auth_status_for(host: &GitHubHost) -> Result<bool>;
}
dir {
fn api(endpoint: &str) -> Result<String>;
fn repo_view() -> Result<RepoView>;
fn pr_list() -> Result<Vec<PullRequest>>;
fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
fn pr_view(number: u64) -> Result<PullRequest>;
fn issue_list() -> Result<Vec<Issue>>;
fn pr_create(spec: PrCreate) -> Result<String>;
fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
fn pr_mark_ready(number: u64) -> Result<()>;
fn pr_close(number: u64, spec: PrClose) -> Result<()>;
fn pr_checkout(number: u64) -> Result<()>;
fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
fn pr_comment(number: u64, body: &str) -> Result<String>;
fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
fn pr_feedback(number: u64) -> Result<PrFeedback>;
fn pr_diff(number: u64) -> Result<Vec<FileDiff>>;
fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
fn run_view(id: u64) -> Result<WorkflowRun>;
fn run_watch(id: u64) -> Result<WorkflowRun>;
fn issue_create(title: &str, body: &str) -> Result<String>;
fn issue_view(number: u64) -> Result<Issue>;
fn release_list() -> Result<Vec<Release>>;
fn release_view(tag: &str) -> Result<Release>;
}
raw {
fn run(args: &[String]) -> Result<String> => run_in;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
fn run_args(args: &[&str]) -> Result<String> => run_args_in;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_gh() {
assert_eq!(BINARY, "gh");
}
#[tokio::test]
async fn capability_version_gate_parses_and_gates() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(
["gh", "--version"],
Reply::ok(
"gh version 2.40.1 (2024-01-05)\nhttps://github.com/cli/cli/releases/tag/v2.40.1\n",
),
));
let caps = gh.capabilities().await.expect("capabilities");
assert_eq!(caps.version.to_string(), "2.40.1");
assert!(caps.is_supported());
caps.ensure_supported().expect("supported");
let at_floor = GitHub::with_runner(
ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version 2.0.0\n")),
);
assert!(
at_floor.capabilities().await.unwrap().is_supported(),
"2.0.0 is exactly the floor"
);
let old = GitHub::with_runner(ScriptedRunner::new().on(
["gh", "--version"],
Reply::ok("gh version 1.14.0 (2021-11-02)\n"),
));
let caps = old.capabilities().await.expect("capabilities");
assert_eq!(
caps.version,
GitHubVersion {
major: 1,
minor: 14,
patch: 0
}
);
assert!(!caps.is_supported(), "1.14 is below the 2.0 floor");
let err = caps.ensure_supported().expect_err("unsupported");
let Error::Spawn { source, .. } = &err else {
panic!("expected Spawn, got {err:?}");
};
let message = source.to_string();
assert!(message.contains(">= 2.0.0"), "names the floor: {message}");
assert!(
message.contains("1.14.0"),
"names the found version: {message}"
);
let garbage = GitHub::with_runner(
ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version unknowable\n")),
);
let err = garbage.capabilities().await.expect_err("unrecognisable");
assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
}
#[allow(dead_code)]
fn bound_view_is_copy_for_default_runner() {
fn assert_copy<T: Copy>() {}
assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
}
#[tokio::test]
async fn bound_view_matches_dir_taking_calls() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec);
gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
gh.run_list(dir, 3, None).await.unwrap();
gh.at(dir).run_list(3, None).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), calls[1].args_str());
assert_eq!(calls[2].args_str(), calls[3].args_str());
assert_eq!(calls[1].cwd.as_deref(), Some(dir));
}
#[tokio::test]
async fn bound_view_raw_hatch_runs_in_bound_dir() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.at(dir)
.run(&["pr".to_string(), "list".to_string()])
.await
.unwrap();
let _ = gh
.at(dir)
.run_raw(&["pr".to_string(), "list".to_string()])
.await
.unwrap();
gh.at(dir).run_args(&["pr", "list"]).await.unwrap();
let _ = gh.at(dir).run_raw_args(&["pr", "list"]).await.unwrap();
gh.run(&["pr".to_string(), "list".to_string()])
.await
.unwrap();
let _ = gh
.run_raw(&["pr".to_string(), "list".to_string()])
.await
.unwrap();
gh.run_args(&["pr", "list"]).await.unwrap();
let _ = gh.run_raw_args(&["pr", "list"]).await.unwrap();
let calls = rec.calls();
for c in &calls[0..4] {
assert_eq!(
c.cwd.as_deref(),
Some(dir),
"raw call through the bound view runs in the bound dir"
);
assert_eq!(c.args_str(), ["pr", "list"]);
}
for c in &calls[4..8] {
assert_eq!(
c.cwd.as_deref(),
None,
"raw call on the client stays in the process cwd"
);
assert_eq!(c.args_str(), ["pr", "list"]);
}
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let gh =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
}
#[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(["gh", "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(["gh", "auth"], Reply::ok("")));
assert!(yes.auth_status().await.unwrap());
let no = GitHub::with_runner(
ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
);
assert!(!no.auth_status().await.unwrap());
let weird =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
assert!(!weird.auth_status().await.unwrap());
}
#[tokio::test]
async fn auth_status_errors_on_timeout() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "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(
[
"gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
],
Reply::ok("https://gh/pr/1\n"),
));
let url = gh
.pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
.await
.expect("should build `pr create … --base main`");
assert_eq!(url, "https://gh/pr/1");
}
#[tokio::test]
async fn pr_create_appends_head_and_base() {
use processkit::testing::RecordingRunner;
let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
let gh = GitHub::with_runner(&rec);
gh.pr_create(
Path::new("/repo"),
PrCreate::new("T", "B").head("feat/x").base("main"),
)
.await
.expect("pr_create");
assert_eq!(
rec.only_call().args_str(),
[
"pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
]
);
}
#[tokio::test]
async fn pr_list_for_branch_filters_and_parses() {
use processkit::testing::RecordingRunner;
let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
let rec = RecordingRunner::replying(Reply::ok(json));
let gh = GitHub::with_runner(&rec);
let prs = gh
.pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
.await
.expect("pr_list_for_branch");
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].title, "Merge feat");
assert_eq!(prs[0].url, "https://gh/pr/9");
assert_eq!(
rec.only_call().args_str(),
[
"pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
"100", "--json", PR_FIELDS
]
);
}
#[tokio::test]
async fn list_methods_pin_limit_100() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec);
gh.pr_list(Path::new("/r")).await.expect("pr_list");
gh.issue_list(Path::new("/r")).await.expect("issue_list");
gh.release_list(Path::new("/r"))
.await
.expect("release_list");
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
["pr", "list", "--limit", "100", "--json", PR_FIELDS]
);
assert_eq!(
calls[1].args_str(),
[
"issue",
"list",
"--limit",
"100",
"--json",
ISSUE_LIST_FIELDS
]
);
assert_eq!(
calls[2].args_str(),
[
"release",
"list",
"--limit",
"100",
"--json",
RELEASE_LIST_FIELDS
]
);
}
#[tokio::test]
async fn pr_create_omits_base_when_none() {
use processkit::testing::RecordingRunner;
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"), PrCreate::new("T", "B"))
.await
.expect("pr_create");
assert_eq!(url, "https://gh/pr/2");
let call = rec.only_call();
assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
assert_eq!(
call.args_str(),
["pr", "create", "--title", "T", "--body", "B"]
);
assert!(!call.has_flag("--base"), "no base was given");
assert!(!call.has_flag("--head"), "no head was given");
}
#[tokio::test]
async fn flag_like_positionals_are_rejected_before_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
assert!(gh.api(Path::new("."), "-evil").await.is_err());
assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
assert!(
gh.api(Path::new("."), "").await.is_err(),
"empty refused too"
);
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[tokio::test]
async fn api_runs_in_the_bound_repo_dir() {
let rec = RecordingRunner::replying(Reply::ok("{}\n"));
let gh = GitHub::with_runner(&rec);
gh.api(Path::new("/repo"), "repos/o/r/pulls")
.await
.expect("api");
let call = rec.only_call();
assert_eq!(call.args_str(), ["api", "repos/o/r/pulls"]);
assert_eq!(call.cwd, Some(std::path::PathBuf::from("/repo")));
}
#[tokio::test]
async fn pr_merge_builds_strategy_and_flags() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
.await
.expect("pr_merge");
assert_eq!(
rec.only_call().args_str(),
["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
);
let bare = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&bare);
gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
.await
.expect("pr_merge");
let call = bare.only_call();
assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
assert!(!call.has_flag("--auto"));
assert!(!call.has_flag("--delete-branch"));
}
#[tokio::test]
async fn pr_mark_ready_and_close_build_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_mark_ready(Path::new("/r"), 3)
.await
.expect("pr_mark_ready");
gh.pr_close(Path::new("/r"), 3, PrClose::new().delete_branch())
.await
.expect("close");
gh.pr_close(Path::new("/r"), 4, PrClose::new())
.await
.expect("close");
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
}
#[tokio::test]
async fn pr_checkout_builds_args_in_repo_dir() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_checkout(Path::new("/repo"), 7)
.await
.expect("pr_checkout");
let call = rec.only_call();
assert_eq!(call.args_str(), ["pr", "checkout", "7"]);
assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.at(Path::new("/repo"))
.pr_checkout(7)
.await
.expect("pr_checkout");
assert_eq!(rec.only_call().args_str(), ["pr", "checkout", "7"]);
}
#[tokio::test]
async fn pr_checks_parses_all_outcome_exit_codes() {
let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
"workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
for reply in [
Reply::ok(json),
Reply::fail(8, "checks pending").with_stdout(json),
Reply::fail(1, "some checks failed").with_stdout(json),
] {
let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].bucket, CheckBucket::Pass);
}
for stderr in [
"no checks reported on the 'feat/x' branch",
"No Checks Reported on the 'feat/x' branch",
] {
let gh = GitHub::with_runner(
ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
);
assert!(
gh.pr_checks(Path::new("."), 7)
.await
.expect("no checks → empty")
.is_empty(),
"no-checks must read as empty for stderr {stderr:?}"
);
}
let gh = GitHub::with_runner(ScriptedRunner::new().on(
["gh", "pr", "checks"],
Reply::fail(1, "no pull requests found for branch 'feat/x'"),
));
assert!(matches!(
gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
Error::Exit { .. }
));
let gh = GitHub::with_runner(
ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
);
assert!(matches!(
gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
Error::Exit { .. }
));
let gh =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
assert!(matches!(
gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
Error::Timeout { .. }
));
}
#[tokio::test]
async fn pr_diff_builds_args_and_parses_scripted_output() {
let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
let rec = RecordingRunner::replying(Reply::ok(out));
let gh = GitHub::with_runner(&rec);
let files = gh.pr_diff(Path::new("/r"), 7).await.expect("pr_diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, std::path::Path::new("m"));
assert_eq!(files[0].change, ChangeKind::Modified);
assert_eq!(
rec.only_call().args_str(),
["pr", "diff", "7", "--color", "never"]
);
}
#[tokio::test]
async fn pr_diff_over_budget_errors_output_too_large() {
let big = "diff --git a/m b/m\n".to_string() + &"+line\n".repeat(20_000);
assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
let gh =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(&big)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
match gh.pr_diff(Path::new("/r"), 7).await {
Err(Error::OutputTooLarge {
program,
max_bytes,
total_bytes,
..
}) => {
assert_eq!(program, "gh");
assert_eq!(max_bytes, Some(64 * 1024));
assert!(total_bytes > 64 * 1024, "actual exceeds allowed");
}
other => panic!("expected OutputTooLarge, got {other:?}"),
}
}
#[tokio::test]
async fn pr_diff_within_override_reads_past_the_default() {
let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
let gh =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(out)))
.default_output_budget(OutputBudget::bytes(4)); assert!(matches!(
gh.pr_diff(Path::new("/r"), 7).await,
Err(Error::OutputTooLarge { .. })
));
let files = gh
.pr_diff_within(Path::new("/r"), 7, OutputBudget::unlimited())
.await
.expect("override reads the diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, std::path::Path::new("m"));
}
#[tokio::test]
async fn run_watch_bounds_output_without_failing_loud() {
let flood = "watching run… job A: running\n".repeat(180_000);
let run_json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
"status":"completed","conclusion":"success","workflowName":"CI",
"headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
let gh = GitHub::with_runner(
ScriptedRunner::new()
.on(["gh", "run", "watch"], Reply::ok(&flood))
.on(["gh", "run", "view"], Reply::ok(run_json)),
);
let run = gh
.run_watch(Path::new("/r"), 42)
.await
.expect("a chatty watch is bounded, not failed loud");
assert_eq!(run.database_id, 42);
}
#[tokio::test]
async fn pr_review_builds_action_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
.await
.expect("approve");
gh.pr_review(
Path::new("/r"),
7,
ReviewAction::request_changes("fix the parser"),
)
.await
.expect("request changes");
gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
.await
.expect("comment");
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
assert!(!calls[0].has_flag("--body"));
assert_eq!(
calls[1].args_str(),
[
"pr",
"review",
"7",
"--request-changes",
"--body",
"fix the parser"
]
);
assert_eq!(
calls[2].args_str(),
["pr", "review", "7", "--comment", "--body", "nice"]
);
}
#[tokio::test]
async fn pr_review_approve_with_body() {
let action = ReviewAction::approve().with_body("LGTM");
assert_eq!(action.kind(), ReviewKind::Approve);
assert_eq!(action.body(), Some("LGTM"));
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_review(Path::new("/r"), 7, action)
.await
.expect("approve with body");
assert_eq!(
rec.only_call().args_str(),
["pr", "review", "7", "--approve", "--body", "LGTM"]
);
}
#[tokio::test]
async fn pr_comment_and_issue_create_return_urls() {
let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
let gh = GitHub::with_runner(&rec);
assert_eq!(
gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
"https://gh/x"
);
assert_eq!(
gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
"https://gh/x"
);
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
["pr", "comment", "7", "--body", "hello"]
);
assert_eq!(
calls[1].args_str(),
["issue", "create", "--title", "T", "--body", "B"]
);
}
#[tokio::test]
async fn pr_edit_emits_only_provided_fields() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
.await
.expect("title-only edit");
gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
.await
.expect("body-only edit");
gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
.await
.expect("both-fields edit");
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
["pr", "edit", "7", "--title", "New title"]
);
assert_eq!(
calls[1].args_str(),
["pr", "edit", "7", "--body", "New body"]
);
assert_eq!(
calls[2].args_str(),
["pr", "edit", "7", "--title", "T", "--body", "B"]
);
}
#[tokio::test]
async fn pr_edit_some_empty_string_clears_field() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
.await
.expect("empty title");
assert_eq!(
rec.only_call().args_str(),
["pr", "edit", "7", "--title", ""]
);
}
#[tokio::test]
async fn with_credentials_injects_gh_token_and_default_does_not() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec)
.with_credentials(Arc::new(StaticCredential::token("tok-123")));
gh.pr_list(Path::new("/r")).await.unwrap();
let call = rec.only_call();
let token = call
.envs
.iter()
.find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(
token,
Some("tok-123"),
"provider token injected as GH_TOKEN"
);
assert!(
!call.args_str().iter().any(|a| a.contains("tok-123")),
"secret must never appear in argv"
);
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec);
gh.pr_list(Path::new("/r")).await.unwrap();
assert!(
!rec.only_call()
.envs
.iter()
.any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
"no provider → no token env (ambient gh auth)"
);
}
#[tokio::test]
async fn with_token_convenience_injects_gh_token() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec).with_token("tok-conv");
gh.pr_list(Path::new("/r")).await.unwrap();
let call = rec.only_call();
let token = call
.envs
.iter()
.find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(token, Some("tok-conv"));
}
#[tokio::test]
async fn provider_returning_none_falls_back_to_ambient() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
gh.pr_list(Path::new("/r")).await.unwrap();
assert!(
!rec.only_call()
.envs
.iter()
.any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
"Ok(None) provider injects no token (ambient)"
);
}
#[tokio::test]
async fn injected_token_overrides_ambient_default_env() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec)
.default_env("GH_TOKEN", "ambient-token")
.with_credentials(Arc::new(StaticCredential::token("provider-token")));
gh.pr_list(Path::new("/r")).await.unwrap();
let call = rec.only_call();
let winner = call
.envs
.iter()
.rev()
.find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(winner, Some("provider-token"), "provider token wins");
}
#[test]
fn github_host_classifies_saas_and_enterprise() {
let saas = GitHubHost::github_com();
assert!(saas.is_github_com() && !saas.is_enterprise());
assert_eq!(saas.as_str(), "github.com");
for h in ["github.com", "GitHub.com", "GITHUB.COM"] {
let host = GitHubHost::new(h).unwrap();
assert!(host.is_github_com(), "{h} should classify as SaaS");
assert_eq!(host.as_str(), "github.com", "canonicalized to lower-case");
}
let ghes = GitHubHost::new("GHE.Example.COM").unwrap();
assert!(ghes.is_enterprise());
assert_eq!(ghes.as_str(), "ghe.example.com");
}
#[test]
fn github_host_new_rejects_malformed_hosts() {
for bad in [
"",
" ",
"-evil",
"has space",
"https://github.com",
"github.com/owner",
"ghe.example.com:8443",
"user@github.com",
".leading",
"trailing.",
] {
let err = GitHubHost::new(bad).unwrap_err();
assert!(
vcs_cli_support::is_invalid_input(&err),
"{bad:?} should be rejected as invalid input, got {err:?}"
);
}
}
#[test]
fn github_host_from_remote_url_parses_and_classifies() {
let cases = [
("https://github.com/o/r.git", "github.com", false),
(
"https://x-access-token:tok@ghe.example.com:8443/o/r",
"ghe.example.com",
true,
),
("http://ghe.internal.corp/o/r", "ghe.internal.corp", true),
("ssh://git@github.com/o/r", "github.com", false),
("ssh://git@ghe.example.com:22/o/r", "ghe.example.com", true),
("git@github.com:o/r.git", "github.com", false),
("git@ghe.example.com:o/r.git", "ghe.example.com", true),
];
for (url, host, enterprise) in cases {
let parsed =
GitHubHost::from_remote_url(url).unwrap_or_else(|e| panic!("parse {url}: {e:?}"));
assert_eq!(parsed.as_str(), host, "host for {url}");
assert_eq!(parsed.is_enterprise(), enterprise, "class for {url}");
}
}
#[test]
fn github_host_from_remote_url_rejects_ambiguous() {
for url in [
"",
" ",
"not-a-url",
"https://",
"ssh://",
"git@internalhost:repo.git",
"C:\\repo\\path",
"https://[::1]:8443/x",
] {
let err = GitHubHost::from_remote_url(url).unwrap_err();
assert!(
vcs_cli_support::is_invalid_input(&err),
"{url:?} should be a diagnosable error, got {err:?}"
);
}
}
#[tokio::test]
async fn with_host_github_com_injects_gh_token() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec)
.with_host(GitHubHost::github_com())
.with_token("saas-tok");
gh.pr_list(Path::new("/r")).await.unwrap();
let call = rec.only_call();
assert!(call.env_is("GH_TOKEN", "saas-tok"));
assert!(
!call.has_env("GH_ENTERPRISE_TOKEN"),
"github.com must not touch the enterprise token env"
);
assert!(call.env_is("GH_HOST", "github.com"));
assert!(!call.args_str().iter().any(|a| a.contains("saas-tok")));
}
#[tokio::test]
async fn with_host_enterprise_injects_enterprise_token_and_host() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec)
.with_host(GitHubHost::new("ghe.example.com").unwrap())
.with_token("ent-tok");
gh.pr_list(Path::new("/r")).await.unwrap();
let call = rec.only_call();
assert!(call.env_is("GH_ENTERPRISE_TOKEN", "ent-tok"));
assert!(
!call.has_env("GH_TOKEN"),
"enterprise token must not land in the github.com env"
);
assert!(call.env_is("GH_HOST", "ghe.example.com"));
assert!(
!call.args_str().iter().any(|a| a.contains("ent-tok")),
"secret must never appear in argv"
);
}
#[tokio::test]
async fn with_host_enterprise_without_credentials_is_ambient() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec).with_host(GitHubHost::new("ghe.corp.example").unwrap());
gh.pr_list(Path::new("/r")).await.unwrap();
let call = rec.only_call();
assert!(!call.has_env("GH_ENTERPRISE_TOKEN"));
assert!(!call.has_env("GH_TOKEN"));
assert!(call.env_is("GH_HOST", "ghe.corp.example"));
}
#[tokio::test]
async fn multiple_hosts_inject_independently() {
let rec_a = RecordingRunner::replying(Reply::ok("[]"));
GitHub::with_runner(&rec_a)
.with_host(GitHubHost::new("ghe.a.example").unwrap())
.with_token("tok-a")
.pr_list(Path::new("/r"))
.await
.unwrap();
let rec_b = RecordingRunner::replying(Reply::ok("[]"));
GitHub::with_runner(&rec_b)
.with_host(GitHubHost::new("ghe.b.example").unwrap())
.with_token("tok-b")
.pr_list(Path::new("/r"))
.await
.unwrap();
let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
GitHub::with_runner(&rec_saas)
.with_host(GitHubHost::github_com())
.with_token("tok-saas")
.pr_list(Path::new("/r"))
.await
.unwrap();
let ca = rec_a.only_call();
assert!(ca.env_is("GH_ENTERPRISE_TOKEN", "tok-a") && ca.env_is("GH_HOST", "ghe.a.example"));
assert!(
!ca.args_str()
.iter()
.any(|s| s.contains("tok-b") || s.contains("tok-saas")),
"host A must not carry another host's secret"
);
let cb = rec_b.only_call();
assert!(cb.env_is("GH_ENTERPRISE_TOKEN", "tok-b") && cb.env_is("GH_HOST", "ghe.b.example"));
let cs = rec_saas.only_call();
assert!(cs.env_is("GH_TOKEN", "tok-saas") && cs.env_is("GH_HOST", "github.com"));
assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
}
#[tokio::test]
async fn host_keyed_provider_injects_only_the_bound_hosts_token() {
let provider: Arc<dyn CredentialProvider> =
Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
Ok(match r.host {
Some("github.com") => Some(Credential::token("saas-secret")),
Some("ghe.example.com") => Some(Credential::token("ent-secret")),
_ => None,
})
}));
let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
GitHub::with_runner(&rec_saas)
.with_host(GitHubHost::github_com())
.with_credentials(Arc::clone(&provider))
.pr_list(Path::new("/r"))
.await
.unwrap();
let cs = rec_saas.only_call();
assert!(cs.env_is("GH_TOKEN", "saas-secret"));
assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
assert!(!cs.args_str().iter().any(|a| a.contains("saas-secret")));
let rec_ent = RecordingRunner::replying(Reply::ok("[]"));
GitHub::with_runner(&rec_ent)
.with_host(GitHubHost::new("ghe.example.com").unwrap())
.with_credentials(Arc::clone(&provider))
.pr_list(Path::new("/r"))
.await
.unwrap();
let ce = rec_ent.only_call();
assert!(ce.env_is("GH_ENTERPRISE_TOKEN", "ent-secret"));
assert!(
!ce.has_env("GH_TOKEN"),
"the enterprise command must not carry the github.com token env"
);
assert!(!ce.args_str().iter().any(|a| a.contains("ent-secret")));
}
#[tokio::test]
async fn provider_none_defers_to_ambient_for_read_and_write() {
let rec_read = RecordingRunner::replying(Reply::ok("[]"));
GitHub::with_runner(&rec_read)
.with_host(GitHubHost::github_com())
.with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
.pr_list(Path::new("/r"))
.await
.unwrap();
let cr = rec_read.only_call();
assert!(
!cr.has_env("GH_TOKEN") && !cr.has_env("GH_ENTERPRISE_TOKEN"),
"read defers to ambient on Ok(None)"
);
let rec_write = RecordingRunner::replying(Reply::ok(""));
GitHub::with_runner(&rec_write)
.with_host(GitHubHost::github_com())
.with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
.pr_merge(Path::new("/r"), 7, PrMerge::squash())
.await
.unwrap();
let cw = rec_write.only_call();
assert!(
!cw.has_env("GH_TOKEN") && !cw.has_env("GH_ENTERPRISE_TOKEN"),
"write defers to ambient on Ok(None)"
);
}
#[tokio::test]
async fn provider_error_aborts_read_and_write_fail_closed() {
fn boom() -> Arc<dyn CredentialProvider> {
Arc::new(provider_fn(|_r: &CredentialRequest<'_>| {
Err(Error::spawn(
BINARY,
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
))
}))
}
let rec_read = RecordingRunner::replying(Reply::ok("[]"));
let read = GitHub::with_runner(&rec_read)
.with_host(GitHubHost::github_com())
.with_credentials(boom())
.pr_list(Path::new("/r"))
.await;
assert!(read.is_err(), "a provider error must abort the read");
assert!(
rec_read.calls().is_empty(),
"gh must not spawn when the provider errored (read)"
);
let rec_write = RecordingRunner::replying(Reply::ok(""));
let write = GitHub::with_runner(&rec_write)
.with_host(GitHubHost::github_com())
.with_credentials(boom())
.pr_merge(Path::new("/r"), 7, PrMerge::squash())
.await;
assert!(write.is_err(), "a provider error must abort the write");
assert!(
rec_write.calls().is_empty(),
"gh must not spawn when the provider errored (write)"
);
}
#[tokio::test]
async fn auth_status_for_scopes_to_hostname() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
let host = GitHubHost::new("ghe.example.com").unwrap();
assert!(gh.auth_status_for(&host).await.unwrap());
assert_eq!(
rec.only_call().args_str(),
["auth", "status", "--hostname", "ghe.example.com"]
);
}
#[tokio::test]
async fn auth_status_for_is_independent_of_other_host_sessions() {
let runner = ScriptedRunner::new()
.on(
["gh", "auth", "status", "--hostname", "broken.example.com"],
Reply::fail(1, "not logged in to broken.example.com"),
)
.on(
["gh", "auth", "status", "--hostname", "good.example.com"],
Reply::ok(""),
);
let gh = GitHub::with_runner(runner);
assert!(
gh.auth_status_for(&GitHubHost::new("good.example.com").unwrap())
.await
.unwrap(),
"the healthy target host reads as authenticated"
);
assert!(
!gh.auth_status_for(&GitHubHost::new("broken.example.com").unwrap())
.await
.unwrap(),
"a broken host reads as not authenticated, independently"
);
}
#[tokio::test]
async fn bound_view_auth_status_for_matches_client() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.at(Path::new("/repo"))
.auth_status_for(&GitHubHost::github_com())
.await
.unwrap();
let call = rec.only_call();
assert_eq!(
call.args_str(),
["auth", "status", "--hostname", "github.com"]
);
assert_eq!(call.cwd.as_deref(), None, "bare method binds no cwd");
}
#[tokio::test]
async fn pr_feedback_requests_reviews_and_comments() {
let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
"body":"","submittedAt":""}],"comments":[]}"#;
let rec =
RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
let gh = GitHub::with_runner(&rec);
let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
assert_eq!(feedback.reviews[0].author, "a");
assert!(feedback.comments.is_empty());
assert_eq!(
rec.only_call().args_str(),
["pr", "view", "7", "--json", "reviews,comments"]
);
}
#[tokio::test]
async fn run_list_appends_branch_only_when_some() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec);
gh.run_list(Path::new("/r"), 5, None).await.expect("list");
gh.run_list(Path::new("/r"), 5, Some("main".into()))
.await
.expect("list");
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
["run", "list", "--limit", "5", "--json", RUN_FIELDS]
);
assert_eq!(
calls[1].args_str(),
[
"run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
]
);
}
#[tokio::test]
async fn run_watch_then_views_final_state() {
let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
"status":"completed","conclusion":"failure","workflowName":"CI",
"headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
.on(["gh", "run", "view"], Reply::ok(json)),
);
let gh = GitHub::with_runner(&rec);
let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
assert_eq!(run.conclusion, "failure");
let calls = rec.calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
assert_eq!(
calls[1].args_str(),
["run", "view", "42", "--json", RUN_FIELDS]
);
}
#[tokio::test]
async fn run_watch_surfaces_timeout_and_watch_errors() {
let rec = RecordingRunner::new(
ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
);
let gh = GitHub::with_runner(&rec);
assert!(matches!(
gh.run_watch(Path::new("."), 42).await.unwrap_err(),
Error::Timeout { .. }
));
assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
let gh = GitHub::with_runner(
ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
);
assert!(matches!(
gh.run_watch(Path::new("."), 42).await.unwrap_err(),
Error::Exit { .. }
));
}
#[tokio::test(start_paused = true)]
async fn run_watch_cancels_via_client_default_token() {
use processkit::CancellationToken;
let token = CancellationToken::new();
let gh =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
.default_cancel_on(token.clone());
let call = gh.run_watch(Path::new("."), 42);
tokio::pin!(call);
assert!(
tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
.await
.is_err(),
"run_watch must park until the token fires"
);
token.cancel();
match call.await {
Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
other => panic!("expected Error::Cancelled, got {other:?}"),
}
}
#[tokio::test]
async fn release_view_requests_view_fields() {
let json = r#"{"tagName":"v1","name":"","body":"notes","url":"u",
"publishedAt":"p","isDraft":false,"isPrerelease":false}"#;
let rec = RecordingRunner::new(
ScriptedRunner::new().on(["gh", "release", "view"], Reply::ok(json)),
);
let gh = GitHub::with_runner(&rec);
let release = gh
.release_view(Path::new("."), "v1")
.await
.expect("release_view");
assert_eq!(release.tag_name, "v1");
assert_eq!(release.body.as_deref(), Some("notes"));
assert_eq!(release.url.as_deref(), Some("u"));
assert_eq!(
rec.only_call().args_str(),
["release", "view", "v1", "--json", RELEASE_VIEW_FIELDS]
);
}
#[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(["gh", "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());
}
}
#[doc = include_str!("../docs/github.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}