#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::Path;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
pub use processkit::CancellationToken;
mod parse;
pub use parse::{
CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, Repo, Review, WorkflowRun,
};
pub const BINARY: &str = "gh";
const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
const ISSUE_LIST_FIELDS: &str = "number,title,state";
const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url";
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(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)]
#[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, 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()
}
}
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait GitHubApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn auth_status(&self) -> Result<bool>;
async fn repo_view(&self, dir: &Path) -> Result<Repo>;
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
async fn pr_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, endpoint: &str) -> Result<String>;
async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()>;
async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()>;
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>;
async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
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>;
}
processkit::cli_client!(
pub struct GitHub => BINARY
);
#[async_trait::async_trait]
impl<R: ProcessRunner> GitHubApi for GitHub<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.run(self.core.command(["--version"])).await
}
async fn auth_status(&self) -> Result<bool> {
Ok(self
.core
.exit_code(self.core.command(["auth", "status"]))
.await?
== 0)
}
async fn repo_view(&self, dir: &Path) -> Result<Repo> {
self.core
.try_parse(
self.core
.command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
parse::parse_repo,
)
.await
}
async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
self.core
.try_parse(
self.core
.command_in(dir, ["pr", "list", "--limit", "100", "--json", PR_FIELDS]),
parse::from_json,
)
.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,
],
),
parse::from_json,
)
.await
}
async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
let n = number.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
parse::from_json,
)
.await
}
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
self.core
.try_parse(
self.core.command_in(
dir,
[
"issue",
"list",
"--limit",
"100",
"--json",
ISSUE_LIST_FIELDS,
],
),
parse::from_json,
)
.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, endpoint: &str) -> Result<String> {
reject_flag_like("endpoint", endpoint)?;
self.core.run(self.core.command(["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_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, delete_branch: bool) -> Result<()> {
let n = number.to_string();
let mut args = vec!["pr", "close", n.as_str()];
if delete_branch {
args.push("--delete-branch");
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
let n = number.to_string();
let res = self
.core
.output(
self.core
.command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
)
.await?;
match res.code() {
Some(0) => parse::from_json(res.stdout()),
Some(1 | 8) if !res.stdout().trim().is_empty() => parse::from_json(res.stdout()),
_ if res.stderr().contains("no checks reported") => Ok(Vec::new()),
_ => {
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_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 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), parse::from_json)
.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]),
parse::from_json,
)
.await
}
async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
let id_str = id.to_string();
self.core
.output(self.core.command_in(dir, ["run", "watch", id_str.as_str()]))
.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],
),
parse::from_json,
)
.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,
],
),
parse::from_json,
)
.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]),
parse::from_json,
)
.await
}
}
impl<R: ProcessRunner> GitHub<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(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> {}
macro_rules! github_at_forwarders {
(
bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
) => {
impl<'a, R: ProcessRunner> GitHubAt<'a, R> {
$(
#[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($bn), "`.")]
pub async fn $bn(&self, $($ba: $bt),*) -> $br {
self.gh.$bn($($ba),*).await
}
)*
$(
#[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
pub async fn $dn(&self, $($da: $dt),*) -> $dr {
self.gh.$dn(self.dir, $($da),*).await
}
)*
}
};
}
github_at_forwarders! {
bare {
fn run(args: &[String]) -> Result<String>;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
fn run_args(args: &[&str]) -> Result<String>;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
fn version() -> Result<String>;
fn auth_status() -> Result<bool>;
fn api(endpoint: &str) -> Result<String>;
}
dir {
fn repo_view() -> Result<Repo>;
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_ready(number: u64) -> Result<()>;
fn pr_close(number: u64, delete_branch: bool) -> 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_feedback(number: u64) -> Result<PrFeedback>;
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>;
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_gh() {
assert_eq!(BINARY, "gh");
}
#[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.as_os_str()));
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(["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(["pr", "list"], Reply::ok(json)));
let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].number, 7);
assert_eq!(prs[0].base_ref_name, "main");
}
#[tokio::test]
async fn auth_status_reads_exit_code() {
let yes = GitHub::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
assert!(yes.auth_status().await.unwrap());
let no = GitHub::with_runner(
ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
);
assert!(!no.auth_status().await.unwrap());
let weird = GitHub::with_runner(ScriptedRunner::new().on(["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(["auth"], Reply::timeout()));
assert!(matches!(
gh.auth_status().await.unwrap_err(),
Error::Timeout { .. }
));
}
#[tokio::test]
async fn pr_create_appends_base_and_returns_url() {
let gh = GitHub::with_runner(ScriptedRunner::new().on(
[
"pr", "create", "--title", "T", "--body", "B", "--base", "main",
],
Reply::ok("https://gh/pr/1\n"),
));
let url = gh
.pr_create(Path::new("."), 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::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::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::RecordingRunner;
use std::ffi::OsStr;
let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
let gh = GitHub::with_runner(&rec);
let url = gh
.pr_create(Path::new("/repo"), 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(OsStr::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("-evil").await.is_err());
assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
assert!(gh.api("").await.is_err(), "empty refused too");
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[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_ready_and_close_build_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let gh = GitHub::with_runner(&rec);
gh.pr_ready(Path::new("/r"), 3).await.expect("pr_ready");
gh.pr_close(Path::new("/r"), 3, true).await.expect("close");
gh.pr_close(Path::new("/r"), 4, false).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_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(["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, "pass");
}
let gh = GitHub::with_runner(ScriptedRunner::new().on(
["pr", "checks"],
Reply::fail(1, "no checks reported on the 'feat/x' branch"),
));
assert!(
gh.pr_checks(Path::new("."), 7)
.await
.expect("no checks → empty")
.is_empty()
);
let gh = GitHub::with_runner(ScriptedRunner::new().on(
["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(["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(["pr", "checks"], Reply::timeout()));
assert!(matches!(
gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
Error::Timeout { .. }
));
}
#[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_feedback_requests_reviews_and_comments() {
let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
"body":"","submittedAt":""}],"comments":[]}"#;
let rec = RecordingRunner::new(ScriptedRunner::new().on(["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(["run", "watch"], Reply::ok("✓ run completed"))
.on(["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(["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(["run", "watch"], Reply::fail(1, "no such run")),
);
assert!(matches!(
gh.run_watch(Path::new("."), 42).await.unwrap_err(),
Error::Exit { .. }
));
}
#[cfg(feature = "cancellation")]
#[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(["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(["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, "notes");
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(["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 {}