#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
pub use vcs_cli_support::{
Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
OutputBudget, Secret, StaticCredential, provider_fn,
};
pub use processkit::{
Error, ErrorKind, ErrorReason, JobRunner, ProcessResult, ProcessRunner, Result,
};
pub use processkit::CancellationToken;
mod parse;
pub use parse::{
CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, RepoView, Review,
Workflow, WorkflowRun,
};
pub use vcs_diff::{ChangeKind, DiffLine, FileDiff, Hunk};
pub use vcs_diff::Version as GitHubVersion;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum PrListState {
#[default]
Open,
Closed,
Merged,
All,
}
impl PrListState {
fn as_arg(self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
Self::Merged => "merged",
Self::All => "all",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrList {
pub state: PrListState,
pub limit: usize,
}
impl PrList {
pub fn new() -> Self {
Self::default()
}
pub fn state(mut self, state: PrListState) -> Self {
self.state = state;
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
}
impl Default for PrList {
fn default() -> Self {
Self {
state: PrListState::Open,
limit: 100,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum IssueListState {
#[default]
Open,
Closed,
All,
}
impl IssueListState {
fn as_arg(self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
Self::All => "all",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct IssueList {
pub state: IssueListState,
pub limit: usize,
}
impl IssueList {
pub fn new() -> Self {
Self::default()
}
pub fn state(mut self, state: IssueListState) -> Self {
self.state = state;
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
}
impl Default for IssueList {
fn default() -> Self {
Self {
state: IssueListState::Open,
limit: 100,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WorkflowList {
pub include_disabled: bool,
pub limit: usize,
}
impl WorkflowList {
pub fn new() -> Self {
Self::default()
}
pub fn all(mut self) -> Self {
self.include_disabled = true;
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
}
impl Default for WorkflowList {
fn default() -> Self {
Self {
include_disabled: false,
limit: 50,
}
}
}
pub const BINARY: &str = "gh";
const PR_FIELDS: &str = "number,title,state,isDraft,headRefName,baseRefName,url,labels,assignees,author,createdAt,updatedAt,milestone";
const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
const ISSUE_LIST_FIELDS: &str =
"number,title,state,body,url,labels,assignees,author,createdAt,updatedAt,milestone";
const ISSUE_VIEW_FIELDS: &str =
"number,title,state,body,url,labels,assignees,author,createdAt,updatedAt,milestone";
const RUN_FIELDS: &str =
"databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
const WORKFLOW_FIELDS: &str = "id,name,path,state";
const WORKFLOW_VIEW_LOOKUP_LIMIT: usize = i32::MAX as usize;
const RUN_WATCH_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(5 * 60);
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,author";
fn reject_flag_like(what: &str, value: &str) -> Result<()> {
vcs_cli_support::reject_flag_like(BINARY, what, value)
}
fn reject_zero_limit(operation: &str, limit: usize) -> Result<()> {
if limit == 0 {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{operation} limit must be greater than zero"),
),
));
}
Ok(())
}
fn reject_invalid_labels(operation: &str, labels: &[String]) -> Result<()> {
if labels.is_empty() || labels.iter().any(|label| label.trim().is_empty()) {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{operation} requires at least one non-empty label"),
),
));
}
Ok(())
}
fn reject_invalid_workflow_dispatch_fields(fields: &[(String, String)]) -> Result<()> {
for (key, _) in fields {
let reason = if key.trim().is_empty() {
"must not be empty"
} else if key.contains('=') {
"must not contain `=`"
} else if key.contains('\0') {
"must not contain NUL"
} else {
continue;
};
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("workflow_dispatch input key {key:?} {reason}"),
),
));
}
Ok(())
}
fn resolve_workflow(workflows: Vec<Workflow>, selector: &str) -> Result<Workflow> {
if selector.is_empty() {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"workflow_view selector must not be empty",
),
));
}
let numeric_id = selector.parse::<u64>().ok();
let selector_lower = selector.to_lowercase();
let is_file = selector_lower.ends_with(".yml") || selector_lower.ends_with(".yaml");
let mut matches: Vec<_> = workflows
.into_iter()
.filter(|workflow| {
if let Some(id) = numeric_id {
workflow.id == id
} else if is_file {
workflow.path == selector
|| workflow
.path
.rsplit('/')
.next()
.is_some_and(|file| file == selector)
} else {
workflow.name.to_lowercase() == selector_lower
}
})
.collect();
match matches.len() {
1 => Ok(matches.pop().expect("length checked")),
0 => Err(Error::parse(
BINARY,
format!("could not find workflow {selector:?}"),
)),
count => Err(Error::parse(
BINARY,
format!("workflow selector {selector:?} is ambiguous ({count} matches)"),
)),
}
}
#[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>,
pub labels: Vec<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,
labels: Vec::new(),
}
}
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
}
pub fn labels(mut self, labels: impl Into<Vec<String>>) -> Self {
self.labels = labels.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct IssueCreate {
pub title: String,
pub body: String,
pub labels: Vec<String>,
}
impl IssueCreate {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
title: title.into(),
body: body.into(),
labels: Vec::new(),
}
}
pub fn labels(mut self, labels: impl Into<Vec<String>>) -> Self {
self.labels = labels.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)]
#[non_exhaustive]
pub struct ReleaseCreate {
pub tag: String,
pub title: Option<String>,
pub notes: Option<String>,
pub draft: bool,
pub prerelease: bool,
}
impl ReleaseCreate {
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
title: None,
notes: None,
draft: false,
prerelease: false,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
pub fn draft(mut self) -> Self {
self.draft = true;
self
}
pub fn prerelease(mut self) -> Self {
self.prerelease = true;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorkflowDispatch {
pub workflow: String,
pub git_ref: Option<String>,
pub fields: Vec<(String, String)>,
}
impl WorkflowDispatch {
pub fn new(workflow: impl Into<String>) -> Self {
Self {
workflow: workflow.into(),
git_ref: None,
fields: Vec::new(),
}
}
pub fn git_ref(mut self, git_ref: impl Into<String>) -> Self {
self.git_ref = Some(git_ref.into());
self
}
pub fn field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push((key.into(), value.into()));
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RerunScope {
All,
FailedOnly,
}
#[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::from(ErrorReason::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>>;
#[allow(unused_variables)]
async fn pr_list_with(&self, dir: &Path, spec: PrList) -> Result<Vec<PullRequest>> {
Err(Error::from(ErrorReason::Unsupported {
operation: "pr_list_with".into(),
}))
}
#[allow(unused_variables)]
async fn pr_list_for_source_branch(&self, dir: &Path, head: &str) -> Result<Vec<PullRequest>> {
Err(Error::from(ErrorReason::Unsupported {
operation: "pr_list_for_source_branch".into(),
}))
}
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>>;
#[allow(unused_variables)]
async fn issue_list_with(&self, dir: &Path, spec: IssueList) -> Result<Vec<Issue>> {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_list_with".into(),
}))
}
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_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "pr_add_labels".into(),
}))
}
#[allow(unused_variables)]
async fn pr_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "pr_remove_labels".into(),
}))
}
#[allow(unused_variables)]
async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
Err(Error::from(ErrorReason::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::from(ErrorReason::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>>;
#[allow(unused_variables)]
async fn workflow_list(&self, dir: &Path) -> Result<Vec<Workflow>> {
Err(Error::from(ErrorReason::Unsupported {
operation: "workflow_list".into(),
}))
}
#[allow(unused_variables)]
async fn workflow_list_with(&self, dir: &Path, spec: WorkflowList) -> Result<Vec<Workflow>> {
Err(Error::from(ErrorReason::Unsupported {
operation: "workflow_list_with".into(),
}))
}
#[allow(unused_variables)]
async fn workflow_view(&self, dir: &Path, selector: &str) -> Result<Workflow> {
Err(Error::from(ErrorReason::Unsupported {
operation: "workflow_view".into(),
}))
}
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>;
#[allow(unused_variables)]
async fn workflow_dispatch(&self, dir: &Path, spec: WorkflowDispatch) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "workflow_dispatch".into(),
}))
}
#[allow(unused_variables)]
async fn run_rerun(&self, dir: &Path, id: u64, scope: RerunScope) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "run_rerun".into(),
}))
}
#[allow(unused_variables)]
async fn run_cancel(&self, dir: &Path, id: u64) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "run_cancel".into(),
}))
}
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
async fn issue_create_with(&self, dir: &Path, spec: IssueCreate) -> Result<String> {
if spec.labels.is_empty() {
self.issue_create(dir, &spec.title, &spec.body).await
} else {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_create_with(labels)".into(),
}))
}
}
#[allow(unused_variables)]
async fn issue_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_add_labels".into(),
}))
}
#[allow(unused_variables)]
async fn issue_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_remove_labels".into(),
}))
}
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
#[allow(unused_variables)]
async fn issue_close(&self, dir: &Path, number: u64) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_close".into(),
}))
}
#[allow(unused_variables)]
async fn issue_reopen(&self, dir: &Path, number: u64) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_reopen".into(),
}))
}
#[allow(unused_variables)]
async fn issue_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
Err(Error::from(ErrorReason::Unsupported {
operation: "issue_comment".into(),
}))
}
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
#[allow(unused_variables)]
async fn release_create(&self, dir: &Path, spec: ReleaseCreate) -> Result<String> {
Err(Error::from(ErrorReason::Unsupported {
operation: "release_create".into(),
}))
}
#[allow(unused_variables)]
async fn release_delete(&self, dir: &Path, tag: &str) -> Result<()> {
Err(Error::from(ErrorReason::Unsupported {
operation: "release_delete".into(),
}))
}
}
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.pr_list_with(dir, PrList::default()).await
}
async fn pr_list_with(&self, dir: &Path, spec: PrList) -> Result<Vec<PullRequest>> {
reject_zero_limit("pr_list_with", spec.limit)?;
let limit = spec.limit.to_string();
self.core
.try_parse(
self.core.command_in(
dir,
[
"pr",
"list",
"--state",
spec.state.as_arg(),
"--limit",
limit.as_str(),
"--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>> {
reject_flag_like("head", head)?;
reject_flag_like("base", base)?;
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_list_for_source_branch(&self, dir: &Path, head: &str) -> Result<Vec<PullRequest>> {
reject_flag_like("head", head)?;
self.core
.try_parse(
self.core.command_in(
dir,
[
"pr", "list", "--head", head, "--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.issue_list_with(dir, IssueList::default()).await
}
async fn issue_list_with(&self, dir: &Path, spec: IssueList) -> Result<Vec<Issue>> {
reject_zero_limit("issue_list_with", spec.limit)?;
let limit = spec.limit.to_string();
self.core
.try_parse(
self.core.command_in(
dir,
[
"issue",
"list",
"--state",
spec.state.as_arg(),
"--limit",
limit.as_str(),
"--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);
}
if !spec.labels.is_empty() {
reject_invalid_labels("pr_create", &spec.labels)?;
for label in &spec.labels {
args.push("--label");
args.push(label);
}
}
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 workflow_list(&self, dir: &Path) -> Result<Vec<Workflow>> {
self.workflow_list_with(dir, WorkflowList::default()).await
}
async fn workflow_list_with(&self, dir: &Path, spec: WorkflowList) -> Result<Vec<Workflow>> {
reject_zero_limit("workflow_list_with", spec.limit)?;
let limit = spec.limit.to_string();
let mut args = vec!["workflow", "list", "--limit", limit.as_str()];
if spec.include_disabled {
args.push("--all");
}
args.extend(["--json", WORKFLOW_FIELDS]);
self.core
.try_parse(self.core.command_in(dir, args), |s| {
vcs_cli_support::json::from_json(BINARY, s)
})
.await
}
async fn workflow_view(&self, dir: &Path, selector: &str) -> Result<Workflow> {
if selector.is_empty() {
return resolve_workflow(Vec::new(), selector);
}
let workflows = self
.workflow_list_with(
dir,
WorkflowList::new().all().limit(WORKFLOW_VIEW_LOOKUP_LIMIT),
)
.await?;
resolve_workflow(workflows, selector)
}
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()])
.inactivity_timeout(RUN_WATCH_INACTIVITY_TIMEOUT)
.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 workflow_dispatch(&self, dir: &Path, spec: WorkflowDispatch) -> Result<()> {
reject_flag_like("workflow", spec.workflow.as_str())?;
reject_invalid_workflow_dispatch_fields(&spec.fields)?;
let fields: Vec<String> = spec
.fields
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
let mut args = vec!["workflow", "run", spec.workflow.as_str()];
if let Some(git_ref) = spec.git_ref.as_deref() {
args.push("--ref");
args.push(git_ref);
}
for field in &fields {
args.push("--raw-field");
args.push(field.as_str());
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn run_rerun(&self, dir: &Path, id: u64, scope: RerunScope) -> Result<()> {
let id = id.to_string();
let mut args = vec!["run", "rerun", id.as_str()];
if scope == RerunScope::FailedOnly {
args.push("--failed");
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn run_cancel(&self, dir: &Path, id: u64) -> Result<()> {
let id = id.to_string();
self.core
.run_unit(self.core.command_in(dir, ["run", "cancel", id.as_str()]))
.await
}
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
self.issue_create_with(dir, IssueCreate::new(title, body))
.await
}
async fn issue_create_with(&self, dir: &Path, spec: IssueCreate) -> Result<String> {
if !spec.labels.is_empty() {
reject_invalid_labels("issue_create_with", &spec.labels)?;
}
let mut args = vec![
"issue",
"create",
"--title",
spec.title.as_str(),
"--body",
spec.body.as_str(),
];
for label in &spec.labels {
args.push("--label");
args.push(label);
}
self.core.run(self.core.command_in(dir, args)).await
}
async fn pr_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
reject_invalid_labels("pr_add_labels", labels)?;
let number = number.to_string();
let mut args = vec!["pr", "edit", number.as_str()];
for label in labels {
args.push("--add-label");
args.push(label);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn pr_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
reject_invalid_labels("pr_remove_labels", labels)?;
let number = number.to_string();
let mut args = vec!["pr", "edit", number.as_str()];
for label in labels {
args.push("--remove-label");
args.push(label);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn issue_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
reject_invalid_labels("issue_add_labels", labels)?;
let number = number.to_string();
let mut args = vec!["issue", "edit", number.as_str()];
for label in labels {
args.push("--add-label");
args.push(label);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn issue_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
reject_invalid_labels("issue_remove_labels", labels)?;
let number = number.to_string();
let mut args = vec!["issue", "edit", number.as_str()];
for label in labels {
args.push("--remove-label");
args.push(label);
}
self.core.run_unit(self.core.command_in(dir, args)).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 issue_close(&self, dir: &Path, number: u64) -> Result<()> {
let n = number.to_string();
self.core
.run_unit(self.core.command_in(dir, ["issue", "close", n.as_str()]))
.await
}
async fn issue_reopen(&self, dir: &Path, number: u64) -> Result<()> {
let n = number.to_string();
self.core
.run_unit(self.core.command_in(dir, ["issue", "reopen", n.as_str()]))
.await
}
async fn issue_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
let n = number.to_string();
self.core
.run(
self.core
.command_in(dir, ["issue", "comment", n.as_str(), "--body", body]),
)
.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
}
async fn release_create(&self, dir: &Path, spec: ReleaseCreate) -> Result<String> {
reject_flag_like("tag", spec.tag.as_str())?;
let mut args = vec!["release", "create", spec.tag.as_str()];
if let Some(title) = spec.title.as_deref() {
args.push("--title");
args.push(title);
}
if let Some(notes) = spec.notes.as_deref() {
args.push("--notes");
args.push(notes);
}
if spec.draft {
args.push("--draft");
}
if spec.prerelease {
args.push("--prerelease");
}
self.core.run(self.core.command_in(dir, args)).await
}
async fn release_delete(&self, dir: &Path, tag: &str) -> Result<()> {
reject_flag_like("tag", tag)?;
self.core
.run_unit(
self.core
.command_in(dir, ["release", "delete", tag, "--yes"]),
)
.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 fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
GitHubAt { gh: self, dir }
}
}
vcs_cli_support::raw_run_forwarders! {
GitHub, "gh", "\"pr\", \"list\"", ", so `gh` infers the repo from `dir`'s remote",
"only the working directory is bound, no `-R`/extra flag is injected"
}
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_with(spec: PrList) -> Result<Vec<PullRequest>>;
fn pr_list_for_source_branch(head: &str) -> 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 issue_list_with(spec: IssueList) -> Result<Vec<Issue>>;
fn pr_create(spec: PrCreate) -> Result<String>;
fn pr_add_labels(number: u64, labels: &[String]) -> Result<()>;
fn pr_remove_labels(number: u64, labels: &[String]) -> Result<()>;
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 workflow_list() -> Result<Vec<Workflow>>;
fn workflow_list_with(spec: WorkflowList) -> Result<Vec<Workflow>>;
fn workflow_view(selector: &str) -> Result<Workflow>;
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 workflow_dispatch(spec: WorkflowDispatch) -> Result<()>;
fn run_rerun(id: u64, scope: RerunScope) -> Result<()>;
fn run_cancel(id: u64) -> Result<()>;
fn issue_create(title: &str, body: &str) -> Result<String>;
fn issue_create_with(spec: IssueCreate) -> Result<String>;
fn issue_add_labels(number: u64, labels: &[String]) -> Result<()>;
fn issue_remove_labels(number: u64, labels: &[String]) -> Result<()>;
fn issue_view(number: u64) -> Result<Issue>;
fn issue_close(number: u64) -> Result<()>;
fn issue_reopen(number: u64) -> Result<()>;
fn issue_comment(number: u64, body: &str) -> Result<String>;
fn release_list() -> Result<Vec<Release>>;
fn release_view(tag: &str) -> Result<Release>;
fn release_create(spec: ReleaseCreate) -> Result<String>;
fn release_delete(tag: &str) -> Result<()>;
}
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::{RecordReplayRunner, RecordingRunner, Reply, ScriptedRunner};
fn err_reason<T>(out: &Result<T>) -> Option<&ErrorReason> {
out.as_ref().err().map(Error::reason)
}
#[test]
fn binary_name_is_gh() {
assert_eq!(BINARY, "gh");
}
fn cassette_path(name: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/cassettes")
.join(name)
}
#[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 ErrorReason::Spawn { source, .. } = err.reason() 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.reason(), ErrorReason::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 disp = || WorkflowDispatch::new("ci.yml").git_ref("main");
gh.workflow_dispatch(dir, disp()).await.unwrap();
gh.at(dir).workflow_dispatch(disp()).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[4].args_str(), calls[5].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().reason(),
ErrorReason::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 pr_list_for_source_branch_filters_all_states_and_guards_head() {
use processkit::testing::RecordingRunner;
let json = r#"[{"number":9,"title":"Merge feat","state":"CLOSED","headRefName":"feat/x","baseRefName":"release","url":"https://gh/pr/9"}]"#;
let rec = RecordingRunner::replying(Reply::ok(json));
let gh = GitHub::with_runner(&rec);
let prs = gh
.pr_list_for_source_branch(Path::new("/repo"), "feat/x")
.await
.expect("pr_list_for_source_branch");
assert_eq!(prs[0].state, "CLOSED");
assert_eq!(
rec.only_call().args_str(),
[
"pr", "list", "--head", "feat/x", "--state", "all", "--limit", "100", "--json",
PR_FIELDS
]
);
let guarded = GitHub::with_runner(ScriptedRunner::new());
assert!(
guarded
.pr_list_for_source_branch(Path::new("/repo"), "--state=open")
.await
.is_err()
);
assert!(
guarded
.pr_list_for_branch(Path::new("/repo"), "feat", "--state=open")
.await
.is_err()
);
}
#[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", "--state", "open", "--limit", "100", "--json", PR_FIELDS
]
);
assert_eq!(
calls[1].args_str(),
[
"issue",
"list",
"--state",
"open",
"--limit",
"100",
"--json",
ISSUE_LIST_FIELDS
]
);
assert_eq!(
calls[2].args_str(),
[
"release",
"list",
"--limit",
"100",
"--json",
RELEASE_LIST_FIELDS
]
);
}
#[tokio::test]
async fn list_specs_map_state_and_limit_and_reject_zero() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec);
gh.pr_list_with(
Path::new("/r"),
PrList::new().state(PrListState::Merged).limit(7),
)
.await
.expect("merged PR list");
gh.issue_list_with(
Path::new("/r"),
IssueList::new().state(IssueListState::All).limit(9),
)
.await
.expect("all issue list");
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
[
"pr", "list", "--state", "merged", "--limit", "7", "--json", PR_FIELDS
]
);
assert_eq!(
calls[1].args_str(),
[
"issue",
"list",
"--state",
"all",
"--limit",
"9",
"--json",
ISSUE_LIST_FIELDS
]
);
let guarded = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&guarded);
assert!(
gh.pr_list_with(Path::new("/r"), PrList::new().limit(0))
.await
.is_err()
);
assert!(guarded.calls().is_empty(), "zero limit must not spawn");
}
#[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 release_create_builds_argv_and_returns_url() {
let rec = RecordingRunner::replying(Reply::ok("https://gh/releases/v1.2.0\n"));
let gh = GitHub::with_runner(&rec);
let url = gh
.release_create(
Path::new("/repo"),
ReleaseCreate::new("v1.2.0")
.title("v1.2.0")
.notes("Notes")
.draft()
.prerelease(),
)
.await
.expect("release_create");
assert_eq!(url, "https://gh/releases/v1.2.0");
let call = rec.only_call();
assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
assert_eq!(
call.args_str(),
[
"release",
"create",
"v1.2.0",
"--title",
"v1.2.0",
"--notes",
"Notes",
"--draft",
"--prerelease"
]
);
}
#[tokio::test]
async fn release_create_omits_unset_options() {
let rec = RecordingRunner::replying(Reply::ok("https://gh/releases/v2\n"));
let gh = GitHub::with_runner(&rec);
gh.release_create(Path::new("/r"), ReleaseCreate::new("v2"))
.await
.expect("release_create");
let call = rec.only_call();
assert_eq!(call.args_str(), ["release", "create", "v2"]);
assert!(!call.has_flag("--title"));
assert!(!call.has_flag("--notes"));
assert!(!call.has_flag("--draft"));
assert!(!call.has_flag("--prerelease"));
}
#[tokio::test]
async fn release_delete_builds_argv_with_yes() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.release_delete(Path::new("/r"), "v1.2.0")
.await
.expect("release_delete");
assert_eq!(
rec.only_call().args_str(),
["release", "delete", "v1.2.0", "--yes"]
);
}
#[tokio::test]
async fn release_mutators_reject_flag_like_tag() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
assert!(
gh.release_create(Path::new("."), ReleaseCreate::new("-evil"))
.await
.is_err()
);
assert!(
gh.release_create(Path::new("."), ReleaseCreate::new(""))
.await
.is_err()
);
assert!(gh.release_delete(Path::new("."), "-evil").await.is_err());
assert!(gh.release_delete(Path::new("."), "").await.is_err());
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().reason(),
ErrorReason::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().reason(),
ErrorReason::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().reason(),
ErrorReason::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
.map_err(Error::into_reason)
{
Err(ErrorReason::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!(
err_reason(&gh.pr_diff(Path::new("/r"), 7).await),
Some(ErrorReason::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 issue_close_reopen_and_comment_build_argv() {
let rec = RecordingRunner::replying(Reply::ok("https://gh/i/7#c1\n"));
let gh = GitHub::with_runner(&rec);
gh.issue_close(Path::new("/r"), 7).await.expect("close");
gh.issue_reopen(Path::new("/r"), 7).await.expect("reopen");
assert_eq!(
gh.issue_comment(Path::new("/r"), 7, "ping").await.unwrap(),
"https://gh/i/7#c1"
);
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["issue", "close", "7"]);
assert_eq!(calls[1].args_str(), ["issue", "reopen", "7"]);
assert_eq!(
calls[2].args_str(),
["issue", "comment", "7", "--body", "ping"]
);
}
#[tokio::test]
async fn issue_comment_passes_leading_dash_body_verbatim() {
let rec = RecordingRunner::replying(Reply::ok("https://gh/i/7#c2\n"));
let gh = GitHub::with_runner(&rec);
gh.issue_comment(Path::new("/r"), 7, "- a bullet")
.await
.expect("dash body");
assert_eq!(
rec.only_call().args_str(),
["issue", "comment", "7", "--body", "- a bullet"]
);
}
#[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 workflow_list_builds_default_and_disabled_inclusive_argv() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let gh = GitHub::with_runner(&rec);
gh.workflow_list(Path::new("/r")).await.expect("list");
gh.at(Path::new("/r"))
.workflow_list_with(WorkflowList::new().all().limit(75))
.await
.expect("list all");
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
[
"workflow",
"list",
"--limit",
"50",
"--json",
WORKFLOW_FIELDS
]
);
assert_eq!(
calls[1].args_str(),
[
"workflow",
"list",
"--limit",
"75",
"--all",
"--json",
WORKFLOW_FIELDS
]
);
assert_eq!(calls[1].cwd.as_deref(), Some(Path::new("/r")));
}
#[tokio::test]
async fn workflow_list_rejects_zero_limit_before_spawn() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let err = GitHub::with_runner(&rec)
.workflow_list_with(Path::new("/r"), WorkflowList::new().limit(0))
.await
.unwrap_err();
assert!(vcs_cli_support::is_invalid_input(&err));
assert!(rec.calls().is_empty());
}
#[tokio::test]
async fn workflow_view_resolves_id_name_filename_and_path_from_json_inventory() {
let json = r#"[
{"id":17,"name":"CI","path":".github/workflows/ci.yml","state":"active"},
{"id":18,"name":"Deploy","path":".github/workflows/deploy.yaml","state":"disabled_manually"}
]"#;
let rec = RecordingRunner::new(
ScriptedRunner::new().on(["gh", "workflow", "list"], Reply::ok(json)),
);
let gh = GitHub::with_runner(&rec);
assert_eq!(
gh.workflow_view(Path::new("/r"), "17").await.unwrap().id,
17
);
assert_eq!(
gh.workflow_view(Path::new("/r"), "ci").await.unwrap().id,
17
);
assert_eq!(
gh.workflow_view(Path::new("/r"), "deploy.yaml")
.await
.unwrap()
.id,
18
);
assert_eq!(
gh.workflow_view(Path::new("/r"), ".github/workflows/ci.yml")
.await
.unwrap()
.id,
17
);
for call in rec.calls() {
assert_eq!(
call.args_str(),
[
"workflow",
"list",
"--limit",
WORKFLOW_VIEW_LOOKUP_LIMIT.to_string().as_str(),
"--all",
"--json",
WORKFLOW_FIELDS
]
);
}
}
#[tokio::test]
async fn workflow_view_reports_empty_missing_and_ambiguous_selectors() {
let rec = RecordingRunner::replying(Reply::ok(
r#"[
{"id":17,"name":"CI","path":".github/workflows/ci.yml","state":"active"},
{"id":18,"name":"ci","path":".github/workflows/other.yml","state":"active"}
]"#,
));
let gh = GitHub::with_runner(&rec);
let empty = gh.workflow_view(Path::new("/r"), "").await.unwrap_err();
assert!(vcs_cli_support::is_invalid_input(&empty));
assert!(rec.calls().is_empty(), "empty selector must not spawn");
for selector in ["missing", "CI"] {
assert!(matches!(
gh.workflow_view(Path::new("/r"), selector)
.await
.unwrap_err()
.reason(),
ErrorReason::Parse { .. }
));
}
assert_eq!(rec.calls().len(), 2);
}
#[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().reason(),
ErrorReason::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().reason(),
ErrorReason::Exit { .. }
));
}
#[tokio::test(start_paused = true)]
async fn run_watch_times_out_after_output_inactivity() {
let gh =
GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()));
match gh.run_watch(Path::new("."), 42).await.unwrap_err().reason() {
ErrorReason::Timeout {
timeout,
inactivity,
..
} => {
assert_eq!(*timeout, RUN_WATCH_INACTIVITY_TIMEOUT);
assert!(*inactivity);
}
other => panic!("expected output-inactivity timeout, got {other:?}"),
}
}
#[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(Duration::from_secs(1), &mut call)
.await
.is_err(),
"run_watch must remain pending until cancellation or its inactivity deadline"
);
token.cancel();
match call.await.map_err(Error::into_reason) {
Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
}
}
#[tokio::test]
async fn workflow_dispatch_builds_argv_with_ref_and_inputs() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.workflow_dispatch(
Path::new("/repo"),
WorkflowDispatch::new("release.yml")
.git_ref("main")
.field("name", "scully")
.field("greeting", "hello"),
)
.await
.expect("workflow_dispatch");
let call = rec.only_call();
assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
assert_eq!(
call.args_str(),
[
"workflow",
"run",
"release.yml",
"--ref",
"main",
"--raw-field",
"name=scully",
"--raw-field",
"greeting=hello",
]
);
}
#[tokio::test]
async fn workflow_dispatch_omits_unset_ref_and_allows_dash_value() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.workflow_dispatch(Path::new("/r"), WorkflowDispatch::new("ci.yml"))
.await
.expect("workflow_dispatch");
assert_eq!(rec.calls()[0].args_str(), ["workflow", "run", "ci.yml"]);
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.workflow_dispatch(
Path::new("/r"),
WorkflowDispatch::new("ci.yml").field("flag", "-x"),
)
.await
.expect("workflow_dispatch");
assert_eq!(
rec.only_call().args_str(),
["workflow", "run", "ci.yml", "--raw-field", "flag=-x"]
);
}
#[tokio::test]
async fn workflow_dispatch_rejects_flag_like_workflow() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
assert!(
gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new("-evil"))
.await
.is_err()
);
assert!(
gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new(""))
.await
.is_err()
);
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[tokio::test]
async fn workflow_dispatch_rejects_invalid_input_keys_before_spawning() {
let gh = GitHub::with_runner(ScriptedRunner::new());
for key in ["", "a=b", "\0"] {
let err = gh
.workflow_dispatch(
Path::new("."),
WorkflowDispatch::new("ci.yml").field(key, "value"),
)
.await
.unwrap_err();
assert!(
vcs_cli_support::is_invalid_input(&err),
"{key:?} should be rejected before spawning, got {err:?}"
);
}
}
#[tokio::test]
async fn run_rerun_builds_argv_for_each_scope() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.run_rerun(Path::new("/r"), 42, RerunScope::All)
.await
.expect("rerun all");
gh.run_rerun(Path::new("/r"), 42, RerunScope::FailedOnly)
.await
.expect("rerun failed");
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["run", "rerun", "42"]);
assert!(!calls[0].has_flag("--failed"), "All reruns the whole run");
assert_eq!(calls[1].args_str(), ["run", "rerun", "42", "--failed"]);
}
#[tokio::test]
async fn run_cancel_builds_argv() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.run_cancel(Path::new("/r"), 42).await.expect("cancel");
assert_eq!(rec.only_call().args_str(), ["run", "cancel", "42"]);
}
#[tokio::test]
async fn run_control_surfaces_gh_exit_errors() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(
["gh", "run", "cancel"],
Reply::fail(1, "Cannot cancel a workflow run that is completed"),
));
assert!(matches!(
gh.run_cancel(Path::new("."), 42)
.await
.unwrap_err()
.reason(),
ErrorReason::Exit { .. }
));
let gh = GitHub::with_runner(ScriptedRunner::new().on(
["gh", "workflow", "run"],
Reply::fail(
1,
"HTTP 404: workflow x.yml not found on the default branch",
),
));
assert!(matches!(
gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new("x.yml"))
.await
.unwrap_err()
.reason(),
ErrorReason::Exit { .. }
));
}
#[tokio::test]
async fn release_view_requests_view_fields() {
let cassette = RecordReplayRunner::replay(cassette_path("release_round_trip.json"))
.expect("load recorded release cassette");
let rec = RecordingRunner::new(cassette);
let gh = GitHub::with_runner(&rec);
let releases = gh.release_list(Path::new(".")).await.expect("release_list");
let tag = releases
.first()
.expect("recorded cassette has a release")
.tag_name
.clone();
let release = gh
.release_view(Path::new("."), &tag)
.await
.expect("release_view");
assert_eq!(release.tag_name, tag);
assert!(
release.body.as_deref().is_some_and(|b| !b.is_empty()),
"release notes were recorded"
);
assert!(release.url.as_deref().is_some_and(|u| !u.is_empty()));
let calls = rec.calls();
assert_eq!(calls.len(), 2);
assert_eq!(
calls[1].args_str(),
[
"release",
"view",
tag.as_str(),
"--json",
RELEASE_VIEW_FIELDS
]
);
}
#[tokio::test]
async fn run_list_and_view_replay_recorded_cassette() {
let cassette = RecordReplayRunner::replay(cassette_path("run_round_trip.json"))
.expect("load recorded run cassette");
let rec = RecordingRunner::new(cassette);
let gh = GitHub::with_runner(&rec);
let runs = gh
.run_list(Path::new("."), 3, None)
.await
.expect("run_list");
let first = runs.first().expect("recorded cassette has runs");
assert!(first.database_id > 0);
assert!(!first.workflow_name.is_empty());
let run = gh
.run_view(Path::new("."), first.database_id)
.await
.expect("run_view");
assert_eq!(run.database_id, first.database_id);
assert_eq!(run.workflow_name, first.workflow_name);
let calls = rec.calls();
assert_eq!(calls.len(), 2);
assert_eq!(
calls[0].args_str(),
["run", "list", "--limit", "3", "--json", RUN_FIELDS]
);
assert_eq!(
calls[1].args_str(),
[
"run",
"view",
first.database_id.to_string().as_str(),
"--json",
RUN_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());
}
}
#[cfg(test)]
mod label_tests {
use super::*;
use processkit::testing::{RecordingRunner, Reply};
#[tokio::test]
async fn label_create_and_mutation_argv_are_exact_and_flag_values() {
let rec = RecordingRunner::replying(Reply::ok("https://example.test/1\n"));
let gh = GitHub::with_runner(&rec);
let labels = vec!["-urgent".to_string(), "help wanted".to_string()];
gh.pr_create(
Path::new("/repo"),
PrCreate::new("T", "B").labels(labels.clone()),
)
.await
.unwrap();
gh.issue_create_with(
Path::new("/repo"),
IssueCreate::new("I", "D").labels(labels.clone()),
)
.await
.unwrap();
gh.at(Path::new("/repo"))
.pr_add_labels(7, &labels)
.await
.unwrap();
gh.pr_remove_labels(Path::new("/repo"), 7, &labels)
.await
.unwrap();
gh.issue_add_labels(Path::new("/repo"), 9, &labels)
.await
.unwrap();
gh.issue_remove_labels(Path::new("/repo"), 9, &labels)
.await
.unwrap();
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
[
"pr",
"create",
"--title",
"T",
"--body",
"B",
"--label",
"-urgent",
"--label",
"help wanted"
]
);
assert_eq!(
calls[1].args_str(),
[
"issue",
"create",
"--title",
"I",
"--body",
"D",
"--label",
"-urgent",
"--label",
"help wanted"
]
);
assert_eq!(
calls[2].args_str(),
[
"pr",
"edit",
"7",
"--add-label",
"-urgent",
"--add-label",
"help wanted"
]
);
assert_eq!(calls[2].cwd.as_deref(), Some(Path::new("/repo")));
assert_eq!(
calls[3].args_str(),
[
"pr",
"edit",
"7",
"--remove-label",
"-urgent",
"--remove-label",
"help wanted"
]
);
assert_eq!(
calls[4].args_str(),
[
"issue",
"edit",
"9",
"--add-label",
"-urgent",
"--add-label",
"help wanted"
]
);
assert_eq!(
calls[5].args_str(),
[
"issue",
"edit",
"9",
"--remove-label",
"-urgent",
"--remove-label",
"help wanted"
]
);
}
#[tokio::test]
async fn empty_label_mutation_is_rejected_before_spawn() {
let rec = RecordingRunner::replying(Reply::ok(""));
let err = GitHub::with_runner(&rec)
.pr_add_labels(Path::new("/repo"), 1, &[])
.await
.unwrap_err();
assert!(vcs_cli_support::is_invalid_input(&err));
assert!(rec.calls().is_empty());
}
}
#[doc = include_str!("../docs/github.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}