Expand description
§vcs-github — GitHub CLI guide
vcs-github drives the GitHub CLI (gh) from Rust. Every operation is async,
runs inside an OS job (via processkit) so a gh subprocess is never
orphaned, and returns the structured processkit::Error instead of a stringly
exit. Commands that ask for --json are deserialized into typed structs; the
crate never scrapes human-readable output.
Consumers code against the GitHubApi trait and substitute a fake in tests —
the real GitHub client only appears at the edges. See
Testing & mocking for the two seams.
Requires the gh binary on PATH, authenticated via gh auth login. An
unauthenticated gh surfaces as an Error::Exit (gh’s auth-required exit), not
a silent empty result.
§Construction & configuration
use vcs_github::GitHub;
let gh = GitHub::new(); // GitHub<JobRunner> — the real job-backed clientGitHub::new() builds a client over processkit’s real job-backed runner. Two
knobs and one test seam:
use std::time::Duration;
use processkit::ScriptedRunner;
// Cap every spawned `gh` — a slow/hung command becomes `Error::Timeout`.
let gh = GitHub::new().default_timeout(Duration::from_secs(30));
// Inject a fake process executor instead of spawning `gh` (tests, CI).
let gh = GitHub::with_runner(ScriptedRunner::new());The timeout matters for blocking calls — see run_watch, which
parks for the lifetime of a CI run.
§cwd-bound handle — gh.at(&path)
Most methods take a leading dir: &Path. When you make several calls against
one repo, bind it once and drop the argument:
use std::path::Path;
let gh = GitHub::new();
let at = gh.at(repo); // GitHubAt<'_, R> — Copy, cheap to pass around
let prs = at.pr_list().await?; // == gh.pr_list(repo)
let issues = at.issue_list().await?;gh.at(dir) returns a GitHubAt — a Copy view holding two references. Its
bound methods produce byte-identical argv to the dir-taking calls (the crate
guards this with a test); the only difference is ergonomics. bare methods that
take no dir (version, auth_status, api, the raw escape hatches) forward
verbatim.
§Inherent &[&str] helpers
GitHubApi::run/run_raw take &[String] (the trait must stay object-safe and
mockall-friendly). On the concrete GitHub, two inherent methods take string
slices so you skip the Vec<String> allocation:
let gh = GitHub::new();
let out = gh.run_args(&["pr", "list"]).await?; // String — trimmed stdout
let res = gh.run_raw_args(&["pr", "list"]).await?; // ProcessResult<String> — no error on non-zeroBoth are also available on the bound handle (gh.at(dir).run_args(…)).
§Auth & repo
async fn version(&self) -> Result<String>; // `gh --version`
async fn auth_status(&self) -> Result<bool>; // `gh auth status` exits 0
async fn api(&self, endpoint: &str) -> Result<String>;// `gh api <endpoint>`
async fn repo_view(&self, dir: &Path) -> Result<Repo>;// `gh repo view --json …`auth_status reads the exit code as a bool — gh auth status exits 0 when
authenticated, non-zero when not. But that is the only thing folded into the
bool: a spawn failure, a timeout, or any unexpected exit still errors rather
than reporting a silent false.
let gh = GitHub::new();
match gh.auth_status().await {
Ok(true) => println!("authenticated"),
Ok(false) => println!("not logged in (run `gh auth login`)"),
Err(processkit::Error::Timeout { .. }) => eprintln!("gh timed out"),
Err(e) => eprintln!("{e}"),
}api returns the raw REST/GraphQL response body unparsed — your escape hatch
to any endpoint the typed methods don’t cover. The endpoint is guarded
against flag-injection: a leading - or an empty string is refused before
anything spawns (gh would otherwise parse gh api -evil as a flag).
repo_view flattens gh’s nested owner/defaultBranchRef objects into a flat
Repo — owner is the login string, default_branch is the ref name (empty
for an empty repository).
§Pull requests — listing & creation
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 pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;pr_list returns open PRs (gh’s default). pr_list_for_branch passes
--state all, so a closed or merged PR for the head→base pair is reported
too — branch on each entry’s state. Empty when none match.
pr_create returns the new PR’s URL (trimmed stdout). It takes a
PrCreate spec carrying the title/body and the optional head
(None = the current branch) and base (None = the repo default) branches;
each branch is appended as --head <b> / --base <b> only when set.
use std::path::Path;
let gh = GitHub::new();
for pr in gh.pr_list_for_branch(repo, "feat/streaming", "main").await? {
println!("#{} [{}] {} — {}", pr.number, pr.state, pr.title, pr.url);
}
let url = gh
.pr_create(repo, PrCreate::new("Add streaming", "Implements …")
.head("feat/streaming").base("main"))
.await?;
println!("opened {url}");§Pull requests — lifecycle
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<()>;pr_merge takes a PrMerge config (strategy + optional --auto /
--delete-branch). pr_ready flips a draft to ready-for-review. pr_close
closes without merging, optionally deleting the head branch.
use std::path::Path;
let gh = GitHub::new();
gh.pr_ready(repo, 7).await?;
gh.pr_merge(repo, 7, PrMerge::squash().delete_branch()).await?;
// or bail out:
gh.pr_close(repo, 8, true).await?; // --delete-branch§Pull requests — review & feedback
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>;pr_checks returns the PR’s checks as Vec<CheckRun>. gh encodes the overall
outcome in its exit code — 0 all passed, 8 still pending, 1 some
failed — but prints the same JSON for all three, so the crate parses the list in
every case and lets you branch on each entry’s bucket. A PR with
no checks at all (gh exits 1 with a “no checks reported” message and no JSON)
yields an empty list. Any other non-zero exit — no such PR, auth required,
timeout — is a genuine error. A JSON that fails to parse surfaces as
Error::Parse, never masked by the exit code.
use std::path::Path;
let gh = GitHub::new();
for c in gh.pr_checks(repo, 7).await? {
match c.bucket.as_str() {
"fail" => println!("✗ {} ({})", c.name, c.link),
"pending" => println!("… {}", c.name),
_ => {}
}
}pr_review submits a review described by ReviewAction; the body lives in
the variant because gh requires one for request-changes and comment reviews.
pr_comment adds a conversation comment and returns its URL (--body is
mandatory — without it gh would drop into an interactive prompt and hang a
headless run). pr_feedback fetches the PR’s submitted reviews and conversation
comments into a PrFeedback, flattening gh’s nested author objects (a deleted
account’s null author becomes an empty login).
use std::path::Path;
let gh = GitHub::new();
gh.pr_review(repo, 7, ReviewAction::request_changes("fix the parser")).await?;
let fb = gh.pr_feedback(repo, 7).await?;
for r in &fb.reviews { println!("{} {}", r.author, r.state); }§Issues
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;issue_list fetches only number,title,state — body and url come back
empty (see Issue). issue_view additionally fills body/url.
issue_create returns the new issue’s URL.
use std::path::Path;
let gh = GitHub::new();
let url = gh.issue_create(repo, "Flaky test", "`pr_checks` hangs on …").await?;
let full = gh.issue_view(repo, 3).await?; // body + url populated§Actions runs
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>;run_list returns recent runs, newest first, capped at limit; branch
(owned Option<String>, again for mockall) adds --branch <b> when Some.
run_view fetches one run by its id — which is WorkflowRun::database_id,
not the URL number.
run_watch blocks until the run finishes, then reads its final state via a
follow-up run view. It deliberately omits gh’s --exit-status: that flag
would fold the run’s outcome onto the process exit code, which can’t distinguish
a failed run from a cancelled one — the follow-up view’s
conclusion can. A client default_timeout kills the watch
when it elapses (Error::Timeout), so drive run_watch from a client with no
(or a generous) timeout.
use std::path::Path;
let gh = GitHub::new(); // no default_timeout — the watch may park for minutes
let run = gh.run_watch(repo, 27023111945).await?;
match run.conclusion.as_str() {
"success" => println!("green"),
other => println!("ended: {other}"), // "failure", "cancelled", …
}§Releases
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;release_list returns releases newest first; it does not fetch
body/url (both empty — use release_view), but it is the only endpoint
that reports is_latest. release_view fills body/url for one
tag but has no isLatest field, so is_latest defaults to false there. The
tag is flag-injection guarded like api’s endpoint.
§Raw escape hatches
async fn run(&self, args: &[String]) -> Result<String>; // trimmed stdout; errors on non-zero
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>; // never errors on non-zerorun runs gh <args> and returns trimmed stdout, erroring on a non-zero exit.
run_raw captures the full ProcessResult and never treats a non-zero exit
as an error — inspect .code() / .stdout() / .stderr() yourself. Use these
for any gh subcommand the typed API doesn’t wrap. (The inherent &[&str]
variants run_args / run_raw_args are documented under
Construction.)
§Result types
All result structs are #[non_exhaustive] (match with .., construct via the
crate). Fields populated by some endpoints but not others come back as empty
strings/false, never panicking — note the per-method gaps below.
§PullRequest
From pr_list / pr_list_for_branch / pr_view. Fields:
number: u64, title: String, state: String ("OPEN", "MERGED",
"CLOSED"), head_ref_name: String, base_ref_name: String, url: String.
§Issue
From issue_list (only number, title, state) and issue_view (adds
body, url). Fields: number: u64, title: String, state: String,
body: String — empty from issue_list, url: String — empty from
issue_list.
§WorkflowRun
From run_list / run_view / run_watch. Fields: database_id: u64 (the
<run-id> other commands take), name: String, display_title: String,
status: String ("queued", "in_progress", "completed"),
conclusion: String ("success", "failure", "cancelled", "skipped") —
gh reports an empty string until the run completes (not null),
workflow_name: String, head_branch: String, event: String, url: String,
created_at: String (ISO 8601).
§CheckRun
From pr_checks. Fields: name: String, state: String ("SUCCESS",
"FAILURE", "IN_PROGRESS", …), bucket: String — gh’s categorisation of
state and the field to branch on: one of "pass", "fail", "pending",
"skipping", "cancel"; workflow: String (empty for non-Actions checks),
link: String, started_at: String — empty until the check starts,
completed_at: String — empty until it completes.
§Release
From release_list / release_view. Fields: tag_name: String,
name: String (may be empty), body: String — empty from release_list,
url: String — empty from release_list, published_at: String (ISO 8601,
empty for a draft), is_draft: bool, is_prerelease: bool, is_latest: bool —
only release_list reports this; from release_view it defaults to
false.
§Review
From pr_feedback (pr view --json reviews). Fields: author: String (login;
empty for a deleted account), state: String ("APPROVED",
"CHANGES_REQUESTED", "COMMENTED", "DISMISSED", "PENDING"),
body: String (may be empty), submitted_at: String (ISO 8601).
§Comment
From pr_feedback (pr view --json comments). Fields: author: String (login;
empty for a deleted account), body: String, url: String,
created_at: String (ISO 8601).
§PrFeedback
From pr_feedback. Fields: reviews: Vec<Review> and comments: Vec<Comment>,
each in gh’s order (oldest first).
§Repo
From repo_view, flattening gh’s nested objects. Fields: name: String,
owner: String (the login), description: Option<String> (None when GitHub
returns null), url: String, is_private: bool, default_branch: String
(empty for an empty repository).
§Config types
§MergeStrategy
#[non_exhaustive] enum naming gh’s mutually exclusive strategy flags:
pub enum MergeStrategy {
Merge, // --merge (a merge commit)
Squash, // --squash (one commit)
Rebase, // --rebase (onto the base)
}§PrMerge
The pr_merge options. #[non_exhaustive] — build
it through the strategy constructor, then chain the optional flags, rather than
a struct literal:
let _ = PrMerge::merge(); // --merge
let _ = PrMerge::squash().delete_branch(); // --squash --delete-branch
let _ = PrMerge::rebase().auto(); // --rebase --auto
let _ = PrMerge::squash().auto().delete_branch(); // --squash --auto --delete-branchmerge() / squash() / rebase() pick the strategy (all default auto: false,
delete_branch: false); auto() enables --auto (merge once requirements are
met); delete_branch() enables --delete-branch. Public fields: strategy: MergeStrategy, auto: bool, delete_branch: bool.
§PrCreate
The pr_create options. #[non_exhaustive]
with private-by-spec ergonomics — build through PrCreate::new(title, body) and
chain the optional branch setters rather than a struct literal:
let _ = PrCreate::new("Add streaming", "Implements …"); // current branch → repo default
let _ = PrCreate::new("Add streaming", "Implements …")
.head("feat/streaming").base("main"); // --head feat/streaming --base mainnew(title, body) takes impl Into<String> (source/target left to gh’s
defaults); .head(b) sets --head (the source branch), .base(b) sets --base
(the target). Public fields: title: String, body: String,
head: Option<String>, base: Option<String>.
§ReviewAction
What pr_review submits. Now a
#[non_exhaustive] struct with private fields, so the invariant holds by
construction — gh requires a body for request-changes/comment reviews, so those
are only reachable through the constructors that take one, and an empty-body
request-changes is unrepresentable. The review kind is a separate
ReviewKind enum read back via .kind().
let _ = ReviewAction::approve(); // --approve (no body)
let _ = ReviewAction::approve().with_body("LGTM"); // --approve --body LGTM
let _ = ReviewAction::request_changes("fix the parser"); // --request-changes --body <body>
let _ = ReviewAction::comment("nice"); // --comment --body <body>
let a = ReviewAction::approve().with_body("LGTM");
assert_eq!(a.kind(), ReviewKind::Approve);
assert_eq!(a.body(), Some("LGTM"));approve()— approve with no body; attach one with.with_body(b).request_changes(body)/comment(body)— gh requires the body, so it is taken by construction..with_body(body)— attach or replace the body (mainly to give an approve a message)..kind() -> ReviewKind/.body() -> Option<&str>— read the parts back.
§ReviewKind
#[non_exhaustive], Copy enum naming which review ReviewAction submits, read
back via ReviewAction::kind:
pub enum ReviewKind {
Approve, // --approve
RequestChanges, // --request-changes
Comment, // --comment
}§See also
- Testing & mocking — the
mockfeature (MockGitHubApi) and theScriptedRunnerseam. - Process model & errors — OS-job containment, timeouts, and
the
Error/ProcessResultshapes. - crate docs — quickstart and crate-level docs.