vcs_github/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-github` — automate GitHub from Rust by driving the `gh` CLI.
4//!
5//! You call typed `async` methods; `vcs-github` runs the real `gh`, parses its
6//! output, and hands you structured values — so you get *gh's own* behaviour, auth,
7//! and host resolution, not a reimplementation of the GitHub REST/GraphQL API.
8//! Async, structured errors, mockable. Every command runs inside an OS **job** (an
9//! OS-level container that kills the whole process tree if your program exits, via
10//! [`processkit`]) so a `gh` subprocess is never orphaned, with an optional
11//! per-client [timeout](GitHub::default_timeout). Read-style methods ask `gh` for
12//! `--json` and deserialize it; nothing scrapes human-readable output.
13//!
14//! # What you can do
15//!
16//! Check auth · view the repo · the full pull-request lifecycle (list / view /
17//! create / merge / mark-ready / close, review / comment, CI checks, feedback) ·
18//! issues · releases · GitHub Actions runs (list / view / watch). One tiny call to
19//! start:
20//!
21//! ```no_run
22//! use std::path::Path;
23//! use vcs_github::{GitHub, GitHubApi};
24//! # async fn demo() -> Result<(), processkit::Error> {
25//! let gh = GitHub::new();
26//! let prs = gh.pr_list(Path::new(".")).await?; // up to 100 open PRs
27//! # let _ = prs; Ok(()) }
28//! ```
29//!
30//! # The surface (engineering reference)
31//!
32//! - **[`GitHubApi`]** — the object-safe trait every operation lives on. Depend
33//! on `&dyn GitHubApi` (or generically on `impl GitHubApi`) so a test can swap
34//! the real client for a double. Repo-scoped methods take the working
35//! directory as the first argument and return typed results ([`PullRequest`],
36//! [`Issue`], [`RepoView`], [`CheckRun`], [`WorkflowRun`], [`Release`],
37//! [`PrFeedback`], …) or a structured [`Error`].
38//! - **[`GitHub`]** — the real client. [`GitHub::new`] uses the job-backed
39//! runner; [`GitHub::with_runner`] injects a fake one for tests. It is generic
40//! over the [`ProcessRunner`] seam, defaulting to the production runner.
41//! [`with_credentials`](GitHub::with_credentials) attaches a
42//! [`CredentialProvider`] to supply a token per operation (injected as
43//! `GH_TOKEN`, never in `argv`) — opt-in, off by default (ambient `gh` auth).
44//! [`with_host`](GitHub::with_host) targets a specific host (a [`GitHubHost`] —
45//! github.com or a GitHub Enterprise Server host), so the credential lands in
46//! the env var `gh` reads for *that* host (`GH_TOKEN` vs `GH_ENTERPRISE_TOKEN`)
47//! and [`auth_status_for`](GitHubApi::auth_status_for) probes just that host.
48//! - **[`GitHubAt`]** — a cwd-bound view ([`GitHub::at`]) whose methods drop the
49//! leading `dir`, so `gh.at(dir).pr_list()` reads as `gh.pr_list(dir)` — handy
50//! when one client drives one checkout.
51//! - **Method groups** on the trait: PRs ([`pr_list`](GitHubApi::pr_list),
52//! [`pr_view`](GitHubApi::pr_view), [`pr_create`](GitHubApi::pr_create),
53//! [`pr_merge`](GitHubApi::pr_merge), [`pr_mark_ready`](GitHubApi::pr_mark_ready),
54//! [`pr_close`](GitHubApi::pr_close), [`pr_checkout`](GitHubApi::pr_checkout),
55//! [`pr_review`](GitHubApi::pr_review),
56//! [`pr_comment`](GitHubApi::pr_comment), [`pr_edit`](GitHubApi::pr_edit), [`pr_checks`](GitHubApi::pr_checks),
57//! [`pr_feedback`](GitHubApi::pr_feedback), [`pr_diff`](GitHubApi::pr_diff), …); Actions runs
58//! ([`run_list`](GitHubApi::run_list), [`run_view`](GitHubApi::run_view),
59//! [`run_watch`](GitHubApi::run_watch) — *blocking*, bounded by the client
60//! timeout); issues & releases ([`issue_create`](GitHubApi::issue_create),
61//! [`release_view`](GitHubApi::release_view), …); plus the escape hatches
62//! [`run`](GitHubApi::run) / [`api`](GitHubApi::api) for anything unmodelled.
63//! - **Builder specs** for the multi-option commands — [`PrCreate`] (title/body
64//! with optional `head`/`base`), [`PrEdit`] (optional `title` and/or `body`
65//! for `pr edit`), [`PrMerge`] (strategy [`MergeStrategy`],
66//! `--auto`, `--delete-branch`), [`PrClose`] (optional `--delete-branch`), and
67//! [`ReviewAction`] (whose private fields make
68//! an empty-body request-changes unrepresentable) — each `#[non_exhaustive]`,
69//! built with a constructor and chained setters, named after the flags they emit.
70//!
71//! # Recipes
72//!
73//! Read state — depend on the trait so the same code takes a real client or a mock:
74//!
75//! ```no_run
76//! use std::path::Path;
77//! use vcs_github::{GitHub, GitHubApi};
78//! # async fn demo() -> Result<(), processkit::Error> {
79//! let gh = GitHub::new();
80//! let dir = Path::new(".");
81//! let authed = gh.auth_status().await?; // is `gh` logged in?
82//! let open = gh.pr_list(dir).await?; // up to 100 open PRs
83//! # let _ = (authed, open); Ok(()) }
84//! ```
85//!
86//! Mutate through the builder specs — open a PR, approve it, then squash-merge:
87//!
88//! ```no_run
89//! use std::path::Path;
90//! use vcs_github::{GitHub, GitHubApi, PrCreate, PrMerge, ReviewAction};
91//! # async fn demo(gh: &GitHub) -> Result<(), processkit::Error> {
92//! let dir = Path::new(".");
93//! let url = gh.pr_create(dir, PrCreate::new("Add X", "…").base("main")).await?;
94//! gh.pr_review(dir, 7, ReviewAction::approve().with_body("LGTM")).await?;
95//! gh.pr_merge(dir, 7, PrMerge::squash().delete_branch()).await?;
96//! # let _ = url; Ok(()) }
97//! ```
98//!
99//! # Testing
100//!
101//! Two seams: enable the **`mock`** feature for a `mockall`-generated
102//! `MockGitHubApi` (stub whole methods), or inject a
103//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`GitHub::with_runner`]
104//! to exercise the *real* argv-building and parsing against canned output — no
105//! `gh` binary or network needed, so it runs on CI. The cross-cutting testing
106//! patterns live in
107//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
108//!
109//! # Safety
110//!
111//! Caller values placed in a bare positional argv slot (an `api` endpoint, a
112//! release `tag`) are refused before spawning if empty or starting with `-` —
113//! `gh` would parse them as flags. Flag-value slots (`--body <b>`,
114//! `--branch <b>`) are consumed verbatim and need no guard.
115//!
116//! # In-depth guide
117//!
118//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
119//! from `docs/`. See the [`guide`] module.
120
121use std::path::Path;
122use std::sync::Arc;
123
124// The credential seam (the shared managed client behind `GitHub` is generated by
125// `vcs_cli_support::managed_client!`) — re-exported so a consumer can supply a
126// token provider.
127pub use vcs_cli_support::{
128 Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
129 OutputBudget, Secret, StaticCredential, provider_fn,
130};
131// Re-export the processkit types in this crate's public API, so consumers needn't
132// depend on processkit directly — incl. `ProcessRunner` (the `with_runner`/
133// `GitHub<R>` seam) and the `JobRunner` default. (Also brings
134// `Error`/`Result`/`ProcessResult`/`ProcessRunner` into scope here.)
135pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
136// Re-exported so a consumer can name the token for `default_cancel_on` without
137// taking a direct `processkit` dependency. (Cancellation is core in processkit
138// 0.10 — always available, no feature.)
139pub use processkit::CancellationToken;
140
141mod parse;
142pub use parse::{
143 CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, RepoView, Review,
144 WorkflowRun,
145};
146// Re-exported so `vcs_github::FileDiff` (and the types nested in it) resolve
147// without a direct `vcs-diff` dependency — `pr_diff` returns `vcs-diff`'s model
148// verbatim (`gh pr diff` emits the same git-format diff `git diff`/`jj diff
149// --git` do; `crates/diff/src/diff.rs`'s parser is shared, not duplicated).
150pub use vcs_diff::{ChangeKind, DiffLine, FileDiff, Hunk};
151// The parsed `gh --version`, re-exported as `GitHubVersion` — the shared
152// `major.minor.patch` type `vcs-git`/`vcs-jj` also gate on (an alias of
153// `vcs_diff::Version`), so a consumer needn't name `vcs-diff` to read
154// [`GitHubCapabilities::version`].
155pub use vcs_diff::Version as GitHubVersion;
156
157/// Name of the underlying CLI binary this crate drives.
158pub const BINARY: &str = "gh";
159
160const PR_FIELDS: &str = "number,title,state,isDraft,headRefName,baseRefName,url,labels,assignees";
161const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
162const ISSUE_LIST_FIELDS: &str = "number,title,state,body,url,labels,assignees";
163const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url,labels,assignees";
164const RUN_FIELDS: &str =
165 "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
166const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
167const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
168const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease";
169
170/// Injection guard for bare positional argv slots: a caller-supplied value
171/// with a leading `-` is parsed by gh's CLI as a *flag* (verified: `gh api -evil` →
172/// flag parsing), and an empty value changes a command's
173/// meaning. Refuse both before anything spawns. Flag-VALUE positions
174/// (`--body <b>`, `--branch <b>`) need no guard — gh consumes the next token
175/// verbatim there (verified).
176fn reject_flag_like(what: &str, value: &str) -> Result<()> {
177 vcs_cli_support::reject_flag_like(BINARY, what, value)
178}
179
180/// The GitHub host an operation targets: SaaS `github.com` or a **GitHub
181/// Enterprise Server** (GHES) host. `gh` picks the credential environment variable
182/// it reads *per host* — `GH_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN` for a
183/// GHES host — and its `auth status` can be scoped to a single host, so this type
184/// carries that host so the client (1) injects a supplied credential into the
185/// variable `gh` actually reads for it (see [`GitHub::with_host`]) and (2) can
186/// probe auth for exactly that host (see [`GitHubApi::auth_status_for`]).
187///
188/// Build it for github.com ([`github_com`](GitHubHost::github_com)), from a bare
189/// hostname ([`new`](GitHubHost::new)), or from a repository's remote URL
190/// ([`from_remote_url`](GitHubHost::from_remote_url)). A hostname that cannot be
191/// determined is an **error**, never a silent fall back to github.com — so an
192/// ambiguous or unknown host is a diagnosable result at the call site rather than
193/// a quiet authentication against the wrong host with the github.com token.
194///
195/// ```
196/// # use vcs_github::GitHubHost;
197/// let saas = GitHubHost::github_com();
198/// assert!(saas.is_github_com() && !saas.is_enterprise());
199///
200/// let ghes = GitHubHost::new("ghe.example.com").unwrap();
201/// assert!(ghes.is_enterprise());
202/// assert_eq!(ghes.as_str(), "ghe.example.com");
203///
204/// // github.com (any case) classifies as SaaS; every other valid host is GHES.
205/// assert!(GitHubHost::new("GitHub.com").unwrap().is_github_com());
206/// // An unparseable / hostless remote is an error, not a github.com guess.
207/// assert!(GitHubHost::from_remote_url("not-a-url").is_err());
208/// ```
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub struct GitHubHost {
211 /// The canonical (lower-cased) hostname, e.g. `github.com` / `ghe.example.com`.
212 host: String,
213 /// `true` for a GitHub Enterprise Server host; `false` for SaaS github.com.
214 enterprise: bool,
215}
216
217impl GitHubHost {
218 /// The SaaS GitHub hostname (`github.com`).
219 pub const SAAS_HOST: &'static str = "github.com";
220
221 /// The SaaS github.com host — a supplied credential is injected as `GH_TOKEN`.
222 #[must_use]
223 pub fn github_com() -> Self {
224 Self {
225 host: Self::SAAS_HOST.to_string(),
226 enterprise: false,
227 }
228 }
229
230 /// Classify a bare `host`: `github.com` (case-insensitive) is SaaS; any other
231 /// valid hostname is treated as a GitHub Enterprise Server host (its credential
232 /// goes to `GH_ENTERPRISE_TOKEN`). Returns an error for an empty, flag-like, or
233 /// otherwise malformed hostname (a scheme, path, port, userinfo, or whitespace)
234 /// rather than guessing — the value must be a bare DNS-style host.
235 pub fn new(host: impl AsRef<str>) -> Result<Self> {
236 let host = validate_host(host.as_ref())?;
237 let enterprise = host != Self::SAAS_HOST;
238 Ok(Self { host, enterprise })
239 }
240
241 /// Derive the host from a repository **remote URL** and classify it. Handles
242 /// `scheme://[user@]host[:port]/…` (HTTPS/SSH/…) and the scp-like
243 /// `[user@]host:path` SSH form; any userinfo and port are dropped. A remote
244 /// whose host can't be determined (unparseable, hostless, or ambiguous — an
245 /// IPv6 literal, a bare single-label scp authority, a local path) is an
246 /// **error**, not a silent github.com fallback, so the caller can surface an
247 /// ambiguous remote as a diagnosable result.
248 pub fn from_remote_url(url: &str) -> Result<Self> {
249 match host_from_remote_url(url) {
250 Some(host) => Self::new(host),
251 None => Err(invalid_host_error(
252 url,
253 "no GitHub host could be determined from the remote URL",
254 )),
255 }
256 }
257
258 /// The canonical hostname (`github.com`, `ghe.example.com`).
259 #[must_use]
260 pub fn as_str(&self) -> &str {
261 &self.host
262 }
263
264 /// Whether this is a GitHub Enterprise Server host (anything but github.com).
265 #[must_use]
266 pub fn is_enterprise(&self) -> bool {
267 self.enterprise
268 }
269
270 /// Whether this is SaaS github.com.
271 #[must_use]
272 pub fn is_github_com(&self) -> bool {
273 !self.enterprise
274 }
275
276 /// The environment variable `gh` reads for a credential on this host —
277 /// `GH_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN` for a GHES host. `'static`
278 /// so it can seed the client's token-env binding.
279 fn token_env_var(&self) -> &'static str {
280 if self.enterprise {
281 "GH_ENTERPRISE_TOKEN"
282 } else {
283 "GH_TOKEN"
284 }
285 }
286}
287
288/// Validate a bare gh hostname, returning it **lower-cased** (its canonical form —
289/// hostnames are case-insensitive and `gh` stores them lower-cased). A host must
290/// be a non-empty DNS-style name (ASCII letters/digits/`.`/`-`), not start with
291/// `-`/`.` nor end with `.`, and carry no scheme, path, port, userinfo, or
292/// whitespace. Anything else is refused as invalid input — `gh` would misread it,
293/// or it is not a host at all.
294fn validate_host(host: &str) -> Result<String> {
295 let trimmed = host.trim();
296 let well_formed = !trimmed.is_empty()
297 && !trimmed.starts_with('-')
298 && !trimmed.starts_with('.')
299 && !trimmed.ends_with('.')
300 && trimmed
301 .chars()
302 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
303 if !well_formed {
304 return Err(invalid_host_error(host, "not a valid GitHub hostname"));
305 }
306 Ok(trimmed.to_ascii_lowercase())
307}
308
309/// The `Error::Spawn` / `InvalidInput` the crate raises for a rejected caller
310/// value (the same shape as [`reject_flag_like`], classified by
311/// `vcs_cli_support::is_invalid_input`), naming the bad host and why.
312fn invalid_host_error(value: &str, reason: &str) -> Error {
313 Error::spawn(
314 BINARY,
315 std::io::Error::new(
316 std::io::ErrorKind::InvalidInput,
317 format!("GitHub host {value:?}: {reason}"),
318 ),
319 )
320}
321
322/// Extract the hostname from a repository remote URL (HTTPS / SSH / scp-like),
323/// dropping any userinfo and port. Returns `None` when no unambiguous host is
324/// present, so [`GitHubHost::from_remote_url`] surfaces a diagnosable error rather
325/// than defaulting to github.com. An IPv6-literal authority (`[::1]`) and a bare
326/// single-label scp authority (indistinguishable from a Windows drive path) return
327/// `None` too — a GitHub host is a dotted DNS name.
328fn host_from_remote_url(url: &str) -> Option<String> {
329 let url = url.trim();
330 if url.is_empty() {
331 return None;
332 }
333 // scheme://[user@]host[:port]/… (https, http, ssh, git, …). The authority
334 // ends at the first `/`, `?`, or `#`; drop any `user:pass@` userinfo.
335 if let Some((_scheme, rest)) = url.split_once("://") {
336 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
337 let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
338 return strip_port(host_port);
339 }
340 // scp-like SSH: `[user@]host:path` (no scheme). The host ends at the first `:`.
341 if let Some((authority, _path)) = url.split_once(':') {
342 let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
343 // Require a dotted host so a Windows drive path (`C:\…`) or a bare
344 // single-label authority isn't misread as a remote host — those are
345 // ambiguous, and the caller gets a diagnosable error instead of a guess.
346 if host.contains('.') && !host.contains('/') && !host.contains('\\') {
347 return Some(host.to_string());
348 }
349 }
350 None
351}
352
353/// Drop a trailing `:port` from `host[:port]`, refusing an IPv6-literal authority
354/// (`[::1]`) — a GitHub host is never a bracketed literal, and gh names hosts
355/// without a port.
356fn strip_port(host_port: &str) -> Option<String> {
357 if host_port.is_empty() || host_port.starts_with('[') {
358 return None;
359 }
360 Some(
361 host_port
362 .split_once(':')
363 .map_or(host_port, |(h, _)| h)
364 .to_string(),
365 )
366}
367
368/// How [`GitHubApi::pr_merge`] merges the PR — exactly one of gh's mutually
369/// exclusive strategy flags.
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371#[non_exhaustive]
372pub enum MergeStrategy {
373 /// A merge commit (`--merge`).
374 Merge,
375 /// Squash into one commit (`--squash`).
376 Squash,
377 /// Rebase the commits onto the base (`--rebase`).
378 Rebase,
379}
380
381impl MergeStrategy {
382 fn flag(self) -> &'static str {
383 match self {
384 MergeStrategy::Merge => "--merge",
385 MergeStrategy::Squash => "--squash",
386 MergeStrategy::Rebase => "--rebase",
387 }
388 }
389}
390
391/// Options for [`GitHubApi::pr_merge`] (`gh pr merge`).
392///
393/// `#[non_exhaustive]`, so build it through the strategy constructors —
394/// [`merge`](PrMerge::merge) / [`squash`](PrMerge::squash) /
395/// [`rebase`](PrMerge::rebase), then [`auto`](PrMerge::auto) /
396/// [`delete_branch`](PrMerge::delete_branch) — rather than a struct literal.
397#[derive(Debug, Clone)]
398#[non_exhaustive]
399pub struct PrMerge {
400 /// The merge strategy (exactly one of gh's `--merge`/`--squash`/`--rebase`).
401 pub strategy: MergeStrategy,
402 /// Enable auto-merge: merge once requirements are met (`--auto`).
403 pub auto: bool,
404 /// Delete the head branch after the merge (`--delete-branch`).
405 pub delete_branch: bool,
406}
407
408impl PrMerge {
409 /// Merge with a merge commit (`gh pr merge --merge`).
410 pub fn merge() -> Self {
411 Self::with(MergeStrategy::Merge)
412 }
413
414 /// Squash-merge (`gh pr merge --squash`).
415 pub fn squash() -> Self {
416 Self::with(MergeStrategy::Squash)
417 }
418
419 /// Rebase-merge (`gh pr merge --rebase`).
420 pub fn rebase() -> Self {
421 Self::with(MergeStrategy::Rebase)
422 }
423
424 fn with(strategy: MergeStrategy) -> Self {
425 Self {
426 strategy,
427 auto: false,
428 delete_branch: false,
429 }
430 }
431
432 /// Merge automatically once requirements are met (`--auto`).
433 pub fn auto(mut self) -> Self {
434 self.auto = true;
435 self
436 }
437
438 /// Delete the head branch after merging (`--delete-branch`).
439 pub fn delete_branch(mut self) -> Self {
440 self.delete_branch = true;
441 self
442 }
443}
444
445/// Options for [`GitHubApi::pr_close`] (`gh pr close`).
446///
447/// `#[non_exhaustive]`, so build it through [`PrClose::new`] and the chained
448/// [`delete_branch`](PrClose::delete_branch) setter rather than a bare `bool`
449/// (`pr_close(n, true)` doesn't say what `true` does).
450#[derive(Debug, Clone, Default, PartialEq, Eq)]
451#[non_exhaustive]
452pub struct PrClose {
453 /// Delete the head branch after closing the PR (`--delete-branch`).
454 pub delete_branch: bool,
455}
456
457impl PrClose {
458 /// Close the PR, leaving the head branch in place.
459 pub fn new() -> Self {
460 Self::default()
461 }
462
463 /// Delete the head branch after closing (`--delete-branch`).
464 pub fn delete_branch(mut self) -> Self {
465 self.delete_branch = true;
466 self
467 }
468}
469
470/// Options for [`GitHubApi::pr_create`] (`gh pr create`).
471///
472/// `#[non_exhaustive]`, so build it through [`PrCreate::new`] (title + body)
473/// and the chained [`head`](PrCreate::head) / [`base`](PrCreate::base) setters
474/// rather than a struct literal.
475#[derive(Debug, Clone)]
476#[non_exhaustive]
477pub struct PrCreate {
478 /// The PR title (`--title`).
479 pub title: String,
480 /// The PR body (`--body`).
481 pub body: String,
482 /// The source branch (`--head`); `None` = the current branch.
483 pub head: Option<String>,
484 /// The target branch (`--base`); `None` = the repo default.
485 pub base: Option<String>,
486}
487
488impl PrCreate {
489 /// A PR with the given title and body, opened from the current branch into
490 /// the repo default (`gh pr create --title <title> --body <body>`).
491 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
492 Self {
493 title: title.into(),
494 body: body.into(),
495 head: None,
496 base: None,
497 }
498 }
499
500 /// Set the source branch (`--head`).
501 pub fn head(mut self, head: impl Into<String>) -> Self {
502 self.head = Some(head.into());
503 self
504 }
505
506 /// Set the target branch (`--base`).
507 pub fn base(mut self, base: impl Into<String>) -> Self {
508 self.base = Some(base.into());
509 self
510 }
511}
512
513/// Options for [`GitHubApi::pr_edit`] (`gh pr edit`).
514///
515/// `#[non_exhaustive]`, so build it through [`PrEdit::new`] and the chained
516/// [`title`](PrEdit::title) / [`body`](PrEdit::body) setters rather than a
517/// struct literal. At least one of `title` or `body` must be `Some`; both
518/// `None` is rejected by the facade before spawning (an explicit error, not a
519/// silent no-op). An empty string is a real value — gh clears the field on
520/// `--title ""` / `--body ""` — not a `None`.
521#[derive(Debug, Clone, PartialEq, Eq)]
522#[non_exhaustive]
523pub struct PrEdit {
524 /// The new title (`--title`); `None` leaves the title alone.
525 pub title: Option<String>,
526 /// The new body (`--body`); `None` leaves the body alone.
527 pub body: Option<String>,
528}
529
530impl PrEdit {
531 /// An edit that leaves both fields alone (the facade rejects both-`None`
532 /// before reaching the wrapper). Start with this and add what you want to
533 /// change via [`title`](PrEdit::title) / [`body`](PrEdit::body).
534 pub fn new() -> Self {
535 Self {
536 title: None,
537 body: None,
538 }
539 }
540
541 /// Set the new title (`--title`).
542 pub fn title(mut self, title: impl Into<String>) -> Self {
543 self.title = Some(title.into());
544 self
545 }
546
547 /// Set the new body (`--body`).
548 pub fn body(mut self, body: impl Into<String>) -> Self {
549 self.body = Some(body.into());
550 self
551 }
552}
553
554impl Default for PrEdit {
555 fn default() -> Self {
556 Self::new()
557 }
558}
559
560/// Which kind of review [`GitHubApi::pr_review`] submits — match on
561/// [`ReviewAction::kind`] to read it back.
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563#[non_exhaustive]
564pub enum ReviewKind {
565 /// Approve (`--approve`).
566 Approve,
567 /// Request changes (`--request-changes`).
568 RequestChanges,
569 /// A comment-only review (`--comment`).
570 Comment,
571}
572
573/// What [`GitHubApi::pr_review`] submits (`gh pr review`).
574///
575/// The fields are **private** so the invariant holds by construction: gh
576/// *requires* a body for request-changes/comment reviews, so those are only
577/// reachable through [`request_changes`](ReviewAction::request_changes) /
578/// [`comment`](ReviewAction::comment), which both take the body — an empty-body
579/// request-changes is unrepresentable. Approve's body is optional
580/// ([`approve`](ReviewAction::approve) starts with none; attach one with
581/// [`with_body`](ReviewAction::with_body)). Read the parts back via
582/// [`kind`](ReviewAction::kind) / [`body`](ReviewAction::body).
583#[derive(Debug, Clone, PartialEq, Eq)]
584#[non_exhaustive]
585pub struct ReviewAction {
586 kind: ReviewKind,
587 body: Option<String>,
588}
589
590impl ReviewAction {
591 /// Approve, with no body (`--approve`). Attach one with
592 /// [`with_body`](ReviewAction::with_body).
593 pub fn approve() -> Self {
594 Self {
595 kind: ReviewKind::Approve,
596 body: None,
597 }
598 }
599
600 /// Request changes; gh requires the body
601 /// (`--request-changes --body <body>`).
602 pub fn request_changes(body: impl Into<String>) -> Self {
603 Self {
604 kind: ReviewKind::RequestChanges,
605 body: Some(body.into()),
606 }
607 }
608
609 /// A comment-only review; gh requires the body (`--comment --body <body>`).
610 pub fn comment(body: impl Into<String>) -> Self {
611 Self {
612 kind: ReviewKind::Comment,
613 body: Some(body.into()),
614 }
615 }
616
617 /// Attach or replace the body — mainly to give an [`approve`](ReviewAction::approve)
618 /// a message.
619 pub fn with_body(mut self, body: impl Into<String>) -> Self {
620 self.body = Some(body.into());
621 self
622 }
623
624 /// Which kind of review this is.
625 pub fn kind(&self) -> ReviewKind {
626 self.kind
627 }
628
629 /// The review body, if any.
630 pub fn body(&self) -> Option<&str> {
631 self.body.as_deref()
632 }
633}
634
635/// What the installed `gh` binary supports, probed via
636/// [`GitHubApi::capabilities`]. A value type — the client holds no state, so
637/// probe once and keep the result (callers cache it). Mirrors
638/// [`vcs_git::GitCapabilities`](../vcs_git/struct.GitCapabilities.html) /
639/// [`vcs_jj::JjCapabilities`](../vcs_jj/struct.JjCapabilities.html).
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641#[non_exhaustive]
642pub struct GitHubCapabilities {
643 /// The binary's parsed version.
644 pub version: GitHubVersion,
645}
646
647/// The oldest `gh` this crate is written against — **2.0.0**, the first release of
648/// the modern `gh` line. Every command this crate's argv drives lives in 2.x: the
649/// `--json` read surface (`pr`/`issue`/`repo`/`release … --json`, incl.
650/// `pr checks --json`), the `pr edit` / `pr checkout` / `pr ready` lifecycle verbs,
651/// and `api`. A `gh` from the 1.x line is missing parts of that surface, so gating
652/// here lets [`ensure_supported`](GitHubCapabilities::ensure_supported) reject a
653/// too-old binary up front with a clear message instead of letting an operation
654/// fail deep inside gh with a cryptic `unknown command`/`unknown flag`.
655const MIN_SUPPORTED: GitHubVersion = GitHubVersion {
656 major: 2,
657 minor: 0,
658 patch: 0,
659};
660
661impl GitHubCapabilities {
662 /// Whether the binary meets the supported floor (gh ≥ 2.0). Every typed
663 /// operation on [`GitHubApi`] is guaranteed against this minimum.
664 pub fn is_supported(&self) -> bool {
665 self.version >= MIN_SUPPORTED
666 }
667
668 /// Error unless [`is_supported`](Self::is_supported) — a clear "needs gh ≥ 2.0,
669 /// found 1.14.0" instead of a cryptic `unknown command`/`unknown flag` failure
670 /// once an operation reaches a command the old binary lacks. The pre-flight
671 /// check a caller runs before driving operations against an untrusted `gh`.
672 pub fn ensure_supported(&self) -> Result<()> {
673 if self.is_supported() {
674 return Ok(());
675 }
676 Err(Error::spawn(
677 BINARY,
678 std::io::Error::new(
679 std::io::ErrorKind::Unsupported,
680 format!(
681 "vcs-github requires gh >= {MIN_SUPPORTED}, found {}",
682 self.version
683 ),
684 ),
685 ))
686 }
687}
688
689/// The GitHub operations this crate exposes — the interface consumers code
690/// against and mock in tests.
691#[cfg_attr(feature = "mock", mockall::automock)]
692#[async_trait::async_trait]
693pub trait GitHubApi: Send + Sync {
694 /// Run `gh <args>` **in the process's current directory**, returning trimmed
695 /// stdout (throws on a non-zero exit). A raw escape hatch — you supply the whole
696 /// argv, so pass `-R owner/repo` to target a specific repo. This method on the
697 /// client is the **process-cwd** escape hatch; the `at(dir)` bound view's
698 /// [`run`](GitHubAt::run) is instead **bound to `dir`** (it forwards to
699 /// [`GitHub::run_in`], so `gh.at(dir).run(…)` runs in the bound repo's cwd, like
700 /// [`api`](GitHubApi::api)). Use `gh.at(dir).run(…)` (or [`GitHub::run_in`]) for
701 /// the bound repo (T-035).
702 async fn run(&self, args: &[String]) -> Result<String>;
703 /// Like [`GitHubApi::run`] but never errors on a non-zero exit — returns the
704 /// captured [`ProcessResult`].
705 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
706 /// Installed GitHub CLI version (`gh --version`).
707 async fn version(&self) -> Result<String>;
708 /// The installed binary's parsed version, as [`GitHubCapabilities`]
709 /// (`gh --version`). A value type — probe once and keep it; an unrecognisable
710 /// version banner is an [`Error::Parse`]. Gate an operation on a minimum `gh`
711 /// with [`GitHubCapabilities::ensure_supported`].
712 async fn capabilities(&self) -> Result<GitHubCapabilities>;
713 /// Whether the user is authenticated (`gh auth status` exits zero). Reflects
714 /// the exit code as a bool — any non-zero exit reads as `false`, never an
715 /// error; only a spawn failure or timeout errors. Unscoped: it inspects
716 /// *every* configured host, so a broken session for one host can make it
717 /// report `false` even when the host you care about is fine — reach for
718 /// [`auth_status_for`](GitHubApi::auth_status_for) to scope it.
719 async fn auth_status(&self) -> Result<bool>;
720 /// Whether the user is authenticated **for `host`** (`gh auth status
721 /// --hostname <host>` exits zero) — the host-scoped twin of
722 /// [`auth_status`](GitHubApi::auth_status). Scoping to the repository's host
723 /// (build a [`GitHubHost`] from its remote, e.g.
724 /// [`GitHubHost::from_remote_url`]) means a broken or absent session for
725 /// *another* host can't turn this into a false negative for the host you
726 /// target. Like `auth_status`, it folds only the exit code into the bool (any
727 /// non-zero exit → `false`); a spawn failure or timeout still errors.
728 /// **Defaulted** to `Error::Unsupported` so external implementers of the trait
729 /// keep compiling when the crate bumps (only the `GitHub` concrete impl and the
730 /// regenerated `MockGitHubApi` override it).
731 #[allow(unused_variables)]
732 async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
733 Err(Error::Unsupported {
734 operation: "auth_status_for".into(),
735 })
736 }
737 /// The repository for `dir` (`gh repo view --json …`).
738 async fn repo_view(&self, dir: &Path) -> Result<RepoView>;
739 /// Pull requests for `dir` (`gh pr list --limit 100 --json …`). Returns up to
740 /// 100 open PRs; use [`run`](GitHubApi::run) for more.
741 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
742 /// Pull requests that merge `head` into `base`, in any state — open, closed,
743 /// or merged (`gh pr list --head <head> --base <base> --state all --limit 100
744 /// --json …`). Each carries its title, URL, and `state`. Empty when none
745 /// match; returns up to 100 (use [`run`](GitHubApi::run) for more).
746 async fn pr_list_for_branch(
747 &self,
748 dir: &Path,
749 head: &str,
750 base: &str,
751 ) -> Result<Vec<PullRequest>>;
752 /// A single pull request by number (`gh pr view <n> --json …`).
753 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
754 /// Issues for `dir` (`gh issue list --limit 100 --json …`). Returns up to 100
755 /// open issues; use [`run`](GitHubApi::run) for more.
756 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
757 /// Open a pull request, returning its URL (`gh pr create`) — see
758 /// [`PrCreate`] for the title/body and the optional `head` (source branch;
759 /// `None` = current branch) / `base` (target; `None` = repo default).
760 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
761 /// Raw GitHub REST/GraphQL response body (`gh api <endpoint>`), run in `dir` so
762 /// a relative endpoint's `{owner}/{repo}` placeholder resolves against the bound
763 /// repository — not whatever repo the process's current directory happens to be in.
764 async fn api(&self, dir: &Path, endpoint: &str) -> Result<String>;
765
766 // --- PR lifecycle ----------------------------------------------------
767
768 /// Merge a pull request (`gh pr merge <n> --merge|--squash|--rebase
769 /// [--auto] [--delete-branch]`) — see [`PrMerge`].
770 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
771 /// Mark a draft pull request as ready for review (`gh pr ready <n>`).
772 async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()>;
773 /// Close a pull request without merging (`gh pr close <n>
774 /// [--delete-branch]`); see [`PrClose`].
775 async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()>;
776 /// Check out a pull request's branch into the working copy at `dir`
777 /// (`gh pr checkout <n>`) — the head branch is fetched and switched to, so a
778 /// subsequent build/test/edit runs against the PR locally. Mutates the working
779 /// copy. **Defaulted** to `Error::Unsupported` so external implementers of the
780 /// trait keep compiling when the crate bumps (only the `GitHub` concrete impl
781 /// and the regenerated `MockGitHubApi` override it).
782 #[allow(unused_variables)]
783 async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
784 Err(Error::Unsupported {
785 operation: "pr_checkout".into(),
786 })
787 }
788 /// The PR's checks (`gh pr checks <n> --json …`). gh signals the overall
789 /// outcome through its exit code — 0 all passed, 8 still pending, 1 some
790 /// failed — and emits the same JSON either way, so all three return the
791 /// parsed list; branch on each entry's [`bucket`](CheckRun::bucket). A PR
792 /// with no checks at all yields an empty list (gh's "no checks reported"
793 /// exit). Any other exit (no such PR, auth required, …) errors.
794 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
795 /// Submit a review (`gh pr review <n> --approve|--request-changes|--comment
796 /// [--body <body>]`) — see [`ReviewAction`] (request-changes/comment carry a
797 /// required body by construction).
798 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
799 /// Add a conversation comment, returning its URL
800 /// (`gh pr comment <n> --body <body>`).
801 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
802 /// Edit a pull request's title and/or body
803 /// (`gh pr edit <n> [--title <title>] [--body <body>]`). At least one of
804 /// `title` or `body` must be `Some` — the facade rejects both-`None`
805 /// before reaching the wrapper, so the default implementation is
806 /// unreachable in normal use. **Defaulted** to `Error::Unsupported` so
807 /// external implementers of the trait keep compiling when the crate
808 /// bumps.
809 #[allow(unused_variables)]
810 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
811 Err(Error::Unsupported {
812 operation: "pr_edit".into(),
813 })
814 }
815 /// The PR's submitted reviews and conversation comments
816 /// (`gh pr view <n> --json reviews,comments`).
817 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
818 /// The PR's diff, one [`FileDiff`] per changed file (`gh pr diff <n>
819 /// --color never`), through the same unified-diff parser
820 /// [`vcs-git`](https://docs.rs/vcs-git)/[`vcs-jj`](https://docs.rs/vcs-jj)
821 /// use — `gh pr diff` emits the same git-format diff `git diff` does.
822 async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>>;
823
824 // --- Actions runs ------------------------------------------------------
825
826 /// Recent workflow runs, newest first (`gh run list --limit <n>
827 /// [--branch <b>] --json …`). `branch` is an owned `Option<String>` to keep
828 /// the trait `mockall`-friendly.
829 async fn run_list(
830 &self,
831 dir: &Path,
832 limit: u64,
833 branch: Option<String>,
834 ) -> Result<Vec<WorkflowRun>>;
835 /// A single workflow run by id (`gh run view <id> --json …`); the id is
836 /// [`WorkflowRun::database_id`].
837 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
838 /// Block until the run finishes, then return its final state
839 /// (`gh run watch <id>`, then a `run view`). Inspect
840 /// [`conclusion`](WorkflowRun::conclusion) for the outcome — exit codes
841 /// can't distinguish a failed run from a cancelled one.
842 ///
843 /// **Blocks for the whole run.** A client
844 /// [`default_timeout`](GitHub::default_timeout) kills the watch when it
845 /// elapses (`Error::Timeout`) — drive this from a client with no (or a
846 /// generous) timeout.
847 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
848
849 // --- Issues / releases ---------------------------------------------------
850
851 /// Open an issue, returning its URL
852 /// (`gh issue create --title <title> --body <body>`).
853 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
854 /// A single issue by number, with `body`/`url` filled
855 /// (`gh issue view <n> --json …`).
856 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
857 /// Releases, newest first (`gh release list --limit 100 --json …`); `body`/`url`
858 /// are not fetched here — use [`release_view`](GitHubApi::release_view).
859 /// Returns up to 100 releases; use [`run`](GitHubApi::run) for more.
860 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
861 /// A single release by tag, with `body`/`url` filled
862 /// (`gh release view <tag> --json …`). gh reports `is_latest` only from
863 /// [`release_list`](GitHubApi::release_list); here it defaults to `false`.
864 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
865}
866
867vcs_cli_support::managed_client! {
868 /// The real GitHub client. Generic over the [`ProcessRunner`] so tests can inject
869 /// a fake process executor; [`GitHub::new`] uses the real job-backed runner.
870 ///
871 /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient). By default it authenticates through `gh`'s own
872 /// ambient login; attach a [`CredentialProvider`] with
873 /// [`with_credentials`](GitHub::with_credentials) to supply a token per operation
874 /// — it is injected as `GH_TOKEN` on every `gh` invocation (or, after
875 /// [`with_host`](GitHub::with_host) targets a GitHub Enterprise Server host,
876 /// as `GH_ENTERPRISE_TOKEN` — the variable `gh` reads for that host).
877 pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
878}
879
880impl<R: ProcessRunner> GitHub<R> {
881 /// Supply credentials per operation via a [`CredentialProvider`] — opt-in, off
882 /// by default (ambient `gh` auth). The resolved token is injected as `GH_TOKEN`
883 /// on every `gh` invocation, overriding the ambient login for this client.
884 #[must_use]
885 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
886 self.core = self.core.with_credentials(provider);
887 self
888 }
889
890 /// Convenience for the common case: authenticate with a single static `token`,
891 /// injected as `GH_TOKEN`. Shorthand for
892 /// `with_credentials(Arc::new(StaticCredential::token(token)))`.
893 #[must_use]
894 pub fn with_token(self, token: impl Into<Secret>) -> Self {
895 self.with_credentials(Arc::new(StaticCredential::token(token)))
896 }
897
898 /// Convenience: read the token from environment variable `var` at request time
899 /// (injected as `GH_TOKEN`); if `var` is unset/empty, fall back to ambient auth.
900 /// Shorthand for `with_credentials(Arc::new(EnvToken::new(var)))`.
901 #[must_use]
902 pub fn with_env_token(self, var: impl Into<String>) -> Self {
903 self.with_credentials(Arc::new(EnvToken::new(var)))
904 }
905
906 /// Bind this client to a GitHub `host`, so a supplied credential is injected
907 /// into the environment variable `gh` reads for **that** host, and gh's default
908 /// host is set accordingly:
909 ///
910 /// - **github.com** ([`GitHubHost::github_com`]) → the token goes to `GH_TOKEN`
911 /// (the SaaS default, unchanged) and `GH_HOST` is `github.com`.
912 /// - a **GitHub Enterprise Server** host → the token goes to
913 /// `GH_ENTERPRISE_TOKEN` (the variable `gh` uses for a non-github.com host)
914 /// and `GH_HOST` is set to that host, so gh's non-repo commands resolve
915 /// against it. The github.com `GH_TOKEN` is **not** set, so an enterprise
916 /// secret never lands in the github.com token env (nor vice versa).
917 ///
918 /// Compose with [`with_credentials`](GitHub::with_credentials) /
919 /// [`with_token`](GitHub::with_token) / [`with_env_token`](GitHub::with_env_token)
920 /// in either order — the host selects the env var, the provider supplies the
921 /// secret. The bound host also travels in each operation's [`CredentialRequest`],
922 /// so a **host-keyed** provider returns the secret for *this* host and never a
923 /// neighbouring instance's. For several hosts, build **one client per host**:
924 /// each injects only its own host's token, so a broken or missing credential for
925 /// one host can't leak into another. Without a host binding the client behaves
926 /// exactly as before — github.com semantics, credential injected as `GH_TOKEN`,
927 /// and the request carries no host (a host-keyed provider that can't place it
928 /// defers to ambient auth).
929 ///
930 /// `GH_HOST` only steers gh's host inference for commands with **no repository
931 /// context**; a repo-scoped command still resolves its host from the working
932 /// directory's remote, so binding a host does not override a repo you point a
933 /// method at — use a host-bound client with repositories on that host.
934 #[must_use]
935 pub fn with_host(mut self, host: GitHubHost) -> Self {
936 self.core = self
937 .core
938 .with_token_env(CredentialService::GitHub, host.token_env_var())
939 // Carry the (canonical, lower-cased) host into every operation's
940 // `CredentialRequest`, so a host-keyed `CredentialProvider` resolves the
941 // secret for *this* host and nothing else — one instance's token can't
942 // land in another host's `gh` command.
943 .with_expected_host(host.as_str())
944 .default_env("GH_HOST", host.as_str());
945 self
946 }
947}
948
949#[async_trait::async_trait]
950impl<R: ProcessRunner> GitHubApi for GitHub<R> {
951 async fn run(&self, args: &[String]) -> Result<String> {
952 self.core.run(args).await
953 }
954
955 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
956 self.core.output_string(args).await
957 }
958
959 async fn version(&self) -> Result<String> {
960 self.core.run(["--version"]).await
961 }
962
963 async fn capabilities(&self) -> Result<GitHubCapabilities> {
964 let raw = self.version().await?;
965 let version = parse::parse_gh_version(&raw).ok_or_else(|| {
966 Error::parse(
967 BINARY,
968 format!("unrecognisable `gh --version` output: {raw:?}"),
969 )
970 })?;
971 Ok(GitHubCapabilities { version })
972 }
973
974 async fn auth_status(&self) -> Result<bool> {
975 // `gh auth status` exits 0 when authenticated, non-zero when not — an
976 // exit-code answer. `exit_code` reads the exit code without erroring on a
977 // non-zero one (a spawn failure or timeout still errors), so ANY non-zero
978 // exit — not just the documented 1 — maps to "not authenticated" rather
979 // than surfacing as an error. `probe` would reject an unusual exit code.
980 Ok(self.core.exit_code(["auth", "status"]).await? == 0)
981 }
982
983 async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
984 // `--hostname <host>` scopes the probe to one host: `gh auth status` with
985 // no hostname inspects *every* configured host, so a single broken session
986 // (a different host, an expired enterprise login) can flip the exit code
987 // non-zero — a false negative for the host we actually target. Same
988 // exit-code-as-bool contract as `auth_status` (a spawn failure or timeout
989 // still errors — see `exit_code`). `host` is a validated `GitHubHost`, so
990 // the `--hostname` value can never be flag-like or empty.
991 Ok(self
992 .core
993 .exit_code(["auth", "status", "--hostname", host.as_str()])
994 .await?
995 == 0)
996 }
997
998 async fn repo_view(&self, dir: &Path) -> Result<RepoView> {
999 self.core
1000 .try_parse(
1001 self.core
1002 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
1003 parse::parse_repo,
1004 )
1005 .await
1006 }
1007
1008 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
1009 self.core
1010 .try_parse(
1011 self.core
1012 .command_in(dir, ["pr", "list", "--limit", "100", "--json", PR_FIELDS]),
1013 |s| vcs_cli_support::json::from_json(BINARY, s),
1014 )
1015 .await
1016 }
1017
1018 async fn pr_list_for_branch(
1019 &self,
1020 dir: &Path,
1021 head: &str,
1022 base: &str,
1023 ) -> Result<Vec<PullRequest>> {
1024 // `--state all` so a closed/merged PR for this branch pair is reported
1025 // too, not just open ones (gh's default); the caller filters on `state`.
1026 self.core
1027 .try_parse(
1028 self.core.command_in(
1029 dir,
1030 [
1031 "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
1032 "100", "--json", PR_FIELDS,
1033 ],
1034 ),
1035 |s| vcs_cli_support::json::from_json(BINARY, s),
1036 )
1037 .await
1038 }
1039
1040 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
1041 let n = number.to_string();
1042 self.core
1043 .try_parse(
1044 self.core
1045 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
1046 |s| vcs_cli_support::json::from_json(BINARY, s),
1047 )
1048 .await
1049 }
1050
1051 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
1052 self.core
1053 .try_parse(
1054 self.core.command_in(
1055 dir,
1056 [
1057 "issue",
1058 "list",
1059 "--limit",
1060 "100",
1061 "--json",
1062 ISSUE_LIST_FIELDS,
1063 ],
1064 ),
1065 |s| vcs_cli_support::json::from_json(BINARY, s),
1066 )
1067 .await
1068 }
1069
1070 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
1071 let mut args = vec![
1072 "pr",
1073 "create",
1074 "--title",
1075 spec.title.as_str(),
1076 "--body",
1077 spec.body.as_str(),
1078 ];
1079 if let Some(head) = spec.head.as_deref() {
1080 args.push("--head");
1081 args.push(head);
1082 }
1083 if let Some(base) = spec.base.as_deref() {
1084 args.push("--base");
1085 args.push(base);
1086 }
1087 self.core.run(self.core.command_in(dir, args)).await
1088 }
1089
1090 async fn api(&self, dir: &Path, endpoint: &str) -> Result<String> {
1091 reject_flag_like("endpoint", endpoint)?;
1092 self.core
1093 .run(self.core.command_in(dir, ["api", endpoint]))
1094 .await
1095 }
1096
1097 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
1098 let n = number.to_string();
1099 let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
1100 if merge.auto {
1101 args.push("--auto");
1102 }
1103 if merge.delete_branch {
1104 args.push("--delete-branch");
1105 }
1106 self.core.run_unit(self.core.command_in(dir, args)).await
1107 }
1108
1109 async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()> {
1110 let n = number.to_string();
1111 self.core
1112 .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
1113 .await
1114 }
1115
1116 async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()> {
1117 let n = number.to_string();
1118 let mut args = vec!["pr", "close", n.as_str()];
1119 if spec.delete_branch {
1120 args.push("--delete-branch");
1121 }
1122 self.core.run_unit(self.core.command_in(dir, args)).await
1123 }
1124
1125 async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
1126 // `number` is a `u64`, so it can never look like a flag — nothing to
1127 // guard with `reject_flag_like`. `gh pr checkout` fetches the PR's head
1128 // branch and switches the working copy to it (no structured output).
1129 let n = number.to_string();
1130 self.core
1131 .run_unit(self.core.command_in(dir, ["pr", "checkout", n.as_str()]))
1132 .await
1133 }
1134
1135 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
1136 let n = number.to_string();
1137 let res = self
1138 .core
1139 .output_string(
1140 self.core
1141 .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
1142 )
1143 .await?;
1144 match res.code() {
1145 // gh's exit code carries the *overall* outcome (0 = all pass,
1146 // 8 = pending, 1 = some failed) but prints the same JSON for all
1147 // three — parse it and let the caller branch on each `bucket`.
1148 // A parse failure here is a real schema problem and must surface
1149 // as `Error::Parse`, not be masked by the exit code.
1150 Some(0) => vcs_cli_support::json::from_json(BINARY, res.stdout()),
1151 Some(1 | 8) if !res.stdout().trim().is_empty() => {
1152 vcs_cli_support::json::from_json(BINARY, res.stdout())
1153 }
1154 // gh exits 1 with NO JSON for a PR that simply has no checks — the
1155 // one bare non-zero we read as an empty list (cf. jj's
1156 // `resolve_list` and its "No conflicts" exit). Matched
1157 // case-insensitively so a capitalization tweak in gh's wording
1158 // ("no checks reported on the 'X' branch") doesn't turn the empty case
1159 // into a hard error.
1160 _ if res
1161 .stderr()
1162 .to_ascii_lowercase()
1163 .contains("no checks reported") =>
1164 {
1165 Ok(Vec::new())
1166 }
1167 // Anything else (no such PR, auth required, timeout, signal…) is a
1168 // genuine failure; `ensure_success` builds the faithful error.
1169 _ => {
1170 let _ = res.ensure_success()?;
1171 Ok(Vec::new()) // unreachable: a non-zero exit always errors above.
1172 }
1173 }
1174 }
1175
1176 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
1177 let n = number.to_string();
1178 let mut args = vec!["pr", "review", n.as_str()];
1179 args.push(match action.kind() {
1180 ReviewKind::Approve => "--approve",
1181 ReviewKind::RequestChanges => "--request-changes",
1182 ReviewKind::Comment => "--comment",
1183 });
1184 if let Some(body) = action.body() {
1185 args.push("--body");
1186 args.push(body);
1187 }
1188 self.core.run_unit(self.core.command_in(dir, args)).await
1189 }
1190
1191 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
1192 // `--body` is mandatory here: without it gh falls back to an
1193 // interactive prompt, which would hang a headless run.
1194 let n = number.to_string();
1195 self.core
1196 .run(
1197 self.core
1198 .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
1199 )
1200 .await
1201 }
1202
1203 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
1204 // `--title` and `--body` are flag-VALUE positions: gh consumes the
1205 // next token verbatim, so the leading-`-` check is not needed here.
1206 // The facade rejects both-`None` before reaching this; an empty string
1207 // is intentional (clears the field). We still skip absent fields so
1208 // the argv doesn't carry a stray `--title` with no value.
1209 let n = number.to_string();
1210 let mut args = vec!["pr", "edit", n.as_str()];
1211 if let Some(title) = edit.title.as_deref() {
1212 args.push("--title");
1213 args.push(title);
1214 }
1215 if let Some(body) = edit.body.as_deref() {
1216 args.push("--body");
1217 args.push(body);
1218 }
1219 self.core.run_unit(self.core.command_in(dir, args)).await
1220 }
1221
1222 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
1223 let n = number.to_string();
1224 self.core
1225 .try_parse(
1226 self.core.command_in(
1227 dir,
1228 ["pr", "view", n.as_str(), "--json", "reviews,comments"],
1229 ),
1230 parse::parse_feedback,
1231 )
1232 .await
1233 }
1234
1235 async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>> {
1236 self.pr_diff_within(dir, number, self.core.output_budget())
1237 .await
1238 }
1239
1240 async fn run_list(
1241 &self,
1242 dir: &Path,
1243 limit: u64,
1244 branch: Option<String>,
1245 ) -> Result<Vec<WorkflowRun>> {
1246 let limit = limit.to_string();
1247 let mut args = vec!["run", "list", "--limit", limit.as_str()];
1248 if let Some(branch) = branch.as_deref() {
1249 args.push("--branch");
1250 args.push(branch);
1251 }
1252 args.extend(["--json", RUN_FIELDS]);
1253 self.core
1254 .try_parse(self.core.command_in(dir, args), |s| {
1255 vcs_cli_support::json::from_json(BINARY, s)
1256 })
1257 .await
1258 }
1259
1260 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
1261 let id = id.to_string();
1262 self.core
1263 .try_parse(
1264 self.core
1265 .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
1266 |s| vcs_cli_support::json::from_json(BINARY, s),
1267 )
1268 .await
1269 }
1270
1271 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
1272 // Block until the run completes. `--exit-status` is deliberately NOT
1273 // passed: it would map the run's outcome onto the exit code (1 failed,
1274 // 2 cancelled), which can't be reported faithfully — the follow-up
1275 // `run view`'s `conclusion` can. Without it, a non-zero watch exit is a
1276 // genuine error (no such run, auth, …). `output_string` does NOT error on a
1277 // timeout (it returns the result with a timeout flag), so
1278 // `ensure_success` is what surfaces a killed watch as `Error::Timeout`
1279 // instead of reading a half-finished run below.
1280 let id_str = id.to_string();
1281 // `gh run watch` re-prints the full job table every ~3 s until the run ends,
1282 // so over a multi-hour run its stdout grows to tens of MB — all of which we
1283 // discard (only the exit status matters; the result comes from `run_view`).
1284 // Bound the retained buffer (drop-oldest) so a long watch can't accumulate
1285 // unboundedly; the last 256 lines / 256 KiB are plenty for a failure message.
1286 // (`docs/audit-2026-07.md` R5.)
1287 //
1288 // Expressed through the shared [`OutputBudget`] so this fixed watch cap and
1289 // the configurable content-op budget are the *same* mechanism (T-049): this
1290 // is the drop-oldest *diagnostic* projection (`diagnostic_policy`) — a bounded
1291 // tail that never turns a real watch failure into `OutputTooLarge` — not the
1292 // fail-loud *content* projection the diff/show verbs use.
1293 let watch_budget = OutputBudget::bytes(256 * 1024).with_max_lines(256);
1294 let cmd = self
1295 .core
1296 .command_in(dir, ["run", "watch", id_str.as_str()])
1297 .output_buffer(
1298 watch_budget
1299 .diagnostic_policy()
1300 .expect("a byte/line budget yields a diagnostic policy"),
1301 );
1302 let _ = self.core.output_string(cmd).await?.ensure_success()?;
1303 self.run_view(dir, id).await
1304 }
1305
1306 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
1307 self.core
1308 .run(
1309 self.core
1310 .command_in(dir, ["issue", "create", "--title", title, "--body", body]),
1311 )
1312 .await
1313 }
1314
1315 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
1316 let n = number.to_string();
1317 self.core
1318 .try_parse(
1319 self.core.command_in(
1320 dir,
1321 ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
1322 ),
1323 |s| vcs_cli_support::json::from_json(BINARY, s),
1324 )
1325 .await
1326 }
1327
1328 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
1329 self.core
1330 .try_parse(
1331 self.core.command_in(
1332 dir,
1333 [
1334 "release",
1335 "list",
1336 "--limit",
1337 "100",
1338 "--json",
1339 RELEASE_LIST_FIELDS,
1340 ],
1341 ),
1342 |s| vcs_cli_support::json::from_json(BINARY, s),
1343 )
1344 .await
1345 }
1346
1347 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
1348 reject_flag_like("tag", tag)?;
1349 self.core
1350 .try_parse(
1351 self.core
1352 .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
1353 |s| vcs_cli_support::json::from_json(BINARY, s),
1354 )
1355 .await
1356 }
1357}
1358
1359impl<R: ProcessRunner> GitHub<R> {
1360 /// [`pr_diff`](GitHubApi::pr_diff) with an explicit per-call [`OutputBudget`],
1361 /// instead of this client's [`default_output_budget`](GitHub::default_output_budget).
1362 /// Past the ceiling the read errors with
1363 /// [`Error::OutputTooLarge`] (actual and
1364 /// allowed sizes) rather than buffering an unbounded diff — the override for a
1365 /// legitimately huge PR.
1366 pub async fn pr_diff_within(
1367 &self,
1368 dir: &Path,
1369 number: u64,
1370 budget: OutputBudget,
1371 ) -> Result<Vec<FileDiff>> {
1372 // `run_untrimmed_within`: a diff's trailing content is meaningful (a hunk's
1373 // last line, a missing trailing newline) — trimming it before parsing could
1374 // desync the parser from `git`'s own byte-exact output. `--color never` keeps
1375 // the output free of ANSI even if stdout were ever a tty. The budget bounds it.
1376 let n = number.to_string();
1377 let text = self
1378 .core
1379 .run_untrimmed_within(
1380 self.core
1381 .command_in(dir, ["pr", "diff", n.as_str(), "--color", "never"]),
1382 budget,
1383 )
1384 .await?;
1385 Ok(vcs_diff::parse_diff(&text))
1386 }
1387
1388 /// Run `gh <args>` over string slices — `gh.run_args(&["pr", "list"])`
1389 /// without allocating a `Vec<String>`. Inherent (not on the object-safe
1390 /// trait), so it can take `&[&str]`; forwards to the same path as
1391 /// [`GitHubApi::run`].
1392 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1393 self.core.run(args).await
1394 }
1395
1396 /// Like [`run_args`](GitHub::run_args) but never errors on a non-zero exit
1397 /// (mirrors [`GitHubApi::run_raw`]).
1398 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1399 self.core.output_string(args).await
1400 }
1401
1402 /// Run `gh <args>` **in `dir`** (the process is spawned with `dir` as its
1403 /// working directory, so `gh` infers the repo from `dir`'s remote), returning
1404 /// trimmed stdout — the dir-bound twin of the process-cwd [`run`](GitHubApi::run).
1405 /// This is what [`GitHubAt::run`] forwards to; call [`run`](GitHubApi::run) on the
1406 /// client for the process-cwd escape hatch. Argv is forwarded verbatim (only the
1407 /// working directory is bound, no `-R`/extra flag is injected).
1408 pub async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
1409 self.core.run(self.core.command_in(dir, args)).await
1410 }
1411
1412 /// Like [`run_in`](GitHub::run_in) but never errors on a non-zero exit — the
1413 /// dir-bound twin of [`run_raw`](GitHubApi::run_raw). What [`GitHubAt::run_raw`]
1414 /// forwards to.
1415 pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
1416 self.core
1417 .output_string(self.core.command_in(dir, args))
1418 .await
1419 }
1420
1421 /// Like [`run_args`](GitHub::run_args) but **bound to `dir`** — the `&[&str]`
1422 /// twin of [`run_in`](GitHub::run_in). What [`GitHubAt::run_args`] forwards to.
1423 pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
1424 self.core.run(self.core.command_in(dir, args)).await
1425 }
1426
1427 /// Like [`run_raw_args`](GitHub::run_raw_args) but **bound to `dir`** — the
1428 /// `&[&str]` twin of [`run_raw_in`](GitHub::run_raw_in). What
1429 /// [`GitHubAt::run_raw_args`] forwards to.
1430 pub async fn run_raw_args_in(
1431 &self,
1432 dir: &Path,
1433 args: &[&str],
1434 ) -> Result<ProcessResult<String>> {
1435 self.core
1436 .output_string(self.core.command_in(dir, args))
1437 .await
1438 }
1439
1440 /// Bind this client to `dir`, returning a [`GitHubAt`] handle whose `dir`-taking
1441 /// methods omit that argument: `gh.at(dir).pr_list()` runs
1442 /// [`pr_list`](GitHubApi::pr_list) against `dir`.
1443 pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
1444 GitHubAt { gh: self, dir }
1445 }
1446}
1447
1448/// A [`GitHub`] client with a working directory bound, so its repo-scoped methods
1449/// drop the leading `dir` argument (`gh.at(dir).pr_list()`). Construct one with
1450/// [`GitHub::at`].
1451pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
1452 gh: &'a GitHub<R>,
1453 dir: &'a Path,
1454}
1455
1456// Hand-written rather than derived: holding only references, the view is `Copy`
1457// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
1458// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the handle.
1459impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
1460 fn clone(&self) -> Self {
1461 *self
1462 }
1463}
1464impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
1465
1466// Generate [`GitHubAt`] forwarders: `bare` methods forward verbatim, `dir`
1467// methods inject `self.dir` as the first argument. The shared macro lives in
1468// `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
1469vcs_cli_support::at_forwarders! {
1470 GitHubAt, gh, "GitHub",
1471 bare {
1472 fn version() -> Result<String>;
1473 fn capabilities() -> Result<GitHubCapabilities>;
1474 fn auth_status() -> Result<bool>;
1475 fn auth_status_for(host: &GitHubHost) -> Result<bool>;
1476 }
1477 dir {
1478 fn api(endpoint: &str) -> Result<String>;
1479 fn repo_view() -> Result<RepoView>;
1480 fn pr_list() -> Result<Vec<PullRequest>>;
1481 fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
1482 fn pr_view(number: u64) -> Result<PullRequest>;
1483 fn issue_list() -> Result<Vec<Issue>>;
1484 fn pr_create(spec: PrCreate) -> Result<String>;
1485 fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
1486 fn pr_mark_ready(number: u64) -> Result<()>;
1487 fn pr_close(number: u64, spec: PrClose) -> Result<()>;
1488 fn pr_checkout(number: u64) -> Result<()>;
1489 fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
1490 fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
1491 fn pr_comment(number: u64, body: &str) -> Result<String>;
1492 fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
1493 fn pr_feedback(number: u64) -> Result<PrFeedback>;
1494 fn pr_diff(number: u64) -> Result<Vec<FileDiff>>;
1495 fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
1496 fn run_view(id: u64) -> Result<WorkflowRun>;
1497 fn run_watch(id: u64) -> Result<WorkflowRun>;
1498 fn issue_create(title: &str, body: &str) -> Result<String>;
1499 fn issue_view(number: u64) -> Result<Issue>;
1500 fn release_list() -> Result<Vec<Release>>;
1501 fn release_view(tag: &str) -> Result<Release>;
1502 }
1503 // Raw escape hatches: bound to `self.dir` (forward to the client's `*_in`
1504 // twins) so `gh.at(dir).run(…)` targets the bound repo's cwd, not the process
1505 // cwd. For the process-cwd hatch call `run`/`run_raw`/… on `GitHub` directly.
1506 raw {
1507 fn run(args: &[String]) -> Result<String> => run_in;
1508 fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
1509 fn run_args(args: &[&str]) -> Result<String> => run_args_in;
1510 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
1518
1519 #[test]
1520 fn binary_name_is_gh() {
1521 assert_eq!(BINARY, "gh");
1522 }
1523
1524 // `capabilities()` parses the real `gh --version` banner and gates on the 2.0
1525 // floor — covering the minimum, a modern release, and an unrecognisable banner
1526 // (the three cases the scheduled-drift lane also exercises against a real gh).
1527 #[tokio::test]
1528 async fn capability_version_gate_parses_and_gates() {
1529 // Modern gh (the `(date)` trailer and release-URL line are ignored).
1530 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1531 ["gh", "--version"],
1532 Reply::ok(
1533 "gh version 2.40.1 (2024-01-05)\nhttps://github.com/cli/cli/releases/tag/v2.40.1\n",
1534 ),
1535 ));
1536 let caps = gh.capabilities().await.expect("capabilities");
1537 assert_eq!(caps.version.to_string(), "2.40.1");
1538 assert!(caps.is_supported());
1539 caps.ensure_supported().expect("supported");
1540
1541 // Exactly at the floor (2.0.0) is supported.
1542 let at_floor = GitHub::with_runner(
1543 ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version 2.0.0\n")),
1544 );
1545 assert!(
1546 at_floor.capabilities().await.unwrap().is_supported(),
1547 "2.0.0 is exactly the floor"
1548 );
1549
1550 // An old 1.x gh is rejected with a clear message naming the floor + found.
1551 let old = GitHub::with_runner(ScriptedRunner::new().on(
1552 ["gh", "--version"],
1553 Reply::ok("gh version 1.14.0 (2021-11-02)\n"),
1554 ));
1555 let caps = old.capabilities().await.expect("capabilities");
1556 assert_eq!(
1557 caps.version,
1558 GitHubVersion {
1559 major: 1,
1560 minor: 14,
1561 patch: 0
1562 }
1563 );
1564 assert!(!caps.is_supported(), "1.14 is below the 2.0 floor");
1565 let err = caps.ensure_supported().expect_err("unsupported");
1566 let Error::Spawn { source, .. } = &err else {
1567 panic!("expected Spawn, got {err:?}");
1568 };
1569 let message = source.to_string();
1570 assert!(message.contains(">= 2.0.0"), "names the floor: {message}");
1571 assert!(
1572 message.contains("1.14.0"),
1573 "names the found version: {message}"
1574 );
1575
1576 // A banner with no version token is a parse error, not a silent zero.
1577 let garbage = GitHub::with_runner(
1578 ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version unknowable\n")),
1579 );
1580 let err = garbage.capabilities().await.expect_err("unrecognisable");
1581 assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
1582 }
1583
1584 // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
1585 #[allow(dead_code)]
1586 fn bound_view_is_copy_for_default_runner() {
1587 fn assert_copy<T: Copy>() {}
1588 assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
1589 }
1590
1591 // The bound view (`gh.at(dir)`) must produce byte-identical argv to the
1592 // dir-taking call.
1593 #[tokio::test]
1594 async fn bound_view_matches_dir_taking_calls() {
1595 let dir = Path::new("/repo");
1596 let rec = RecordingRunner::replying(Reply::ok("[]"));
1597 let gh = GitHub::with_runner(&rec);
1598
1599 gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
1600 gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
1601 // One of the new lifecycle methods.
1602 gh.run_list(dir, 3, None).await.unwrap();
1603 gh.at(dir).run_list(3, None).await.unwrap();
1604
1605 let calls = rec.calls();
1606 assert_eq!(calls[0].args_str(), calls[1].args_str());
1607 assert_eq!(calls[2].args_str(), calls[3].args_str());
1608 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
1609 }
1610
1611 // T-035: the raw escape hatches reached *through* the bound view
1612 // (`gh.at(dir).run…`) now run in the bound `dir`, while the same-named methods
1613 // on the client stay in the process cwd.
1614 #[tokio::test]
1615 async fn bound_view_raw_hatch_runs_in_bound_dir() {
1616 let dir = Path::new("/repo");
1617 let rec = RecordingRunner::replying(Reply::ok(""));
1618 let gh = GitHub::with_runner(&rec);
1619
1620 // Through the bound view: every raw form carries the bound dir as its cwd.
1621 gh.at(dir)
1622 .run(&["pr".to_string(), "list".to_string()])
1623 .await
1624 .unwrap();
1625 let _ = gh
1626 .at(dir)
1627 .run_raw(&["pr".to_string(), "list".to_string()])
1628 .await
1629 .unwrap();
1630 gh.at(dir).run_args(&["pr", "list"]).await.unwrap();
1631 let _ = gh.at(dir).run_raw_args(&["pr", "list"]).await.unwrap();
1632 // On the client directly: the process-cwd escape hatch (no bound dir).
1633 gh.run(&["pr".to_string(), "list".to_string()])
1634 .await
1635 .unwrap();
1636 let _ = gh
1637 .run_raw(&["pr".to_string(), "list".to_string()])
1638 .await
1639 .unwrap();
1640 gh.run_args(&["pr", "list"]).await.unwrap();
1641 let _ = gh.run_raw_args(&["pr", "list"]).await.unwrap();
1642
1643 let calls = rec.calls();
1644 for c in &calls[0..4] {
1645 assert_eq!(
1646 c.cwd.as_deref(),
1647 Some(dir),
1648 "raw call through the bound view runs in the bound dir"
1649 );
1650 assert_eq!(c.args_str(), ["pr", "list"]);
1651 }
1652 for c in &calls[4..8] {
1653 assert_eq!(
1654 c.cwd.as_deref(),
1655 None,
1656 "raw call on the client stays in the process cwd"
1657 );
1658 assert_eq!(c.args_str(), ["pr", "list"]);
1659 }
1660 }
1661
1662 #[tokio::test]
1663 async fn run_args_forwards_str_slices() {
1664 let gh =
1665 GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
1666 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
1667 }
1668
1669 // Hermetic: real pr_list() arg-building + JSON deserialization against canned
1670 // output — no `gh` binary or network needed, so this runs on CI.
1671 #[tokio::test]
1672 async fn pr_list_parses_scripted_json() {
1673 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
1674 let gh =
1675 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "list"], Reply::ok(json)));
1676 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
1677 assert_eq!(prs.len(), 1);
1678 assert_eq!(prs[0].number, 7);
1679 assert_eq!(prs[0].base_ref_name, "main");
1680 }
1681
1682 // Hermetic: auth_status reflects the exit code without erroring. ANY non-zero
1683 // exit — not just the documented 1 — must read as `false`, never an error
1684 // (an unusual exit code must not be mistaken for a hard failure).
1685 #[tokio::test]
1686 async fn auth_status_reads_exit_code() {
1687 let yes = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::ok("")));
1688 assert!(yes.auth_status().await.unwrap());
1689 let no = GitHub::with_runner(
1690 ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
1691 );
1692 assert!(!no.auth_status().await.unwrap());
1693 // An unexpected exit code (e.g. 2) is still just "not authenticated".
1694 let weird =
1695 GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
1696 assert!(!weird.auth_status().await.unwrap());
1697 }
1698
1699 // Regression guard for the timeout fix: a timed-out auth check must error,
1700 // not silently report "not authenticated" (the old hand-rolled mapping bug).
1701 // Relies on processkit surfacing a timed-out run as `Error::Timeout`.
1702 #[tokio::test]
1703 async fn auth_status_errors_on_timeout() {
1704 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::timeout()));
1705 assert!(matches!(
1706 gh.auth_status().await.unwrap_err(),
1707 Error::Timeout { .. }
1708 ));
1709 }
1710
1711 // pr_create appends `--base <branch>` when given one, and returns the trimmed
1712 // PR URL. The exact command (incl. --base) is the only scripted rule.
1713 #[tokio::test]
1714 async fn pr_create_appends_base_and_returns_url() {
1715 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1716 [
1717 "gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
1718 ],
1719 Reply::ok("https://gh/pr/1\n"),
1720 ));
1721 let url = gh
1722 .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
1723 .await
1724 .expect("should build `pr create … --base main`");
1725 assert_eq!(url, "https://gh/pr/1");
1726 }
1727
1728 // With an explicit head, `pr_create` inserts `--head <branch>` before
1729 // `--base` — so a PR can target an arbitrary source→target pair.
1730 #[tokio::test]
1731 async fn pr_create_appends_head_and_base() {
1732 use processkit::testing::RecordingRunner;
1733 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
1734 let gh = GitHub::with_runner(&rec);
1735 gh.pr_create(
1736 Path::new("/repo"),
1737 PrCreate::new("T", "B").head("feat/x").base("main"),
1738 )
1739 .await
1740 .expect("pr_create");
1741 assert_eq!(
1742 rec.only_call().args_str(),
1743 [
1744 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
1745 ]
1746 );
1747 }
1748
1749 // pr_list_for_branch filters by head + base and parses the PR list (title +
1750 // url available on each result).
1751 #[tokio::test]
1752 async fn pr_list_for_branch_filters_and_parses() {
1753 use processkit::testing::RecordingRunner;
1754 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
1755 let rec = RecordingRunner::replying(Reply::ok(json));
1756 let gh = GitHub::with_runner(&rec);
1757 let prs = gh
1758 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
1759 .await
1760 .expect("pr_list_for_branch");
1761 assert_eq!(prs.len(), 1);
1762 assert_eq!(prs[0].title, "Merge feat");
1763 assert_eq!(prs[0].url, "https://gh/pr/9");
1764 assert_eq!(
1765 rec.only_call().args_str(),
1766 [
1767 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
1768 "100", "--json", PR_FIELDS
1769 ]
1770 );
1771 }
1772
1773 // The list methods pin an explicit `--limit 100` so the CLI's default page
1774 // size (30) does not silently truncate the result.
1775 #[tokio::test]
1776 async fn list_methods_pin_limit_100() {
1777 let rec = RecordingRunner::replying(Reply::ok("[]"));
1778 let gh = GitHub::with_runner(&rec);
1779 gh.pr_list(Path::new("/r")).await.expect("pr_list");
1780 gh.issue_list(Path::new("/r")).await.expect("issue_list");
1781 gh.release_list(Path::new("/r"))
1782 .await
1783 .expect("release_list");
1784 let calls = rec.calls();
1785 assert_eq!(
1786 calls[0].args_str(),
1787 ["pr", "list", "--limit", "100", "--json", PR_FIELDS]
1788 );
1789 assert_eq!(
1790 calls[1].args_str(),
1791 [
1792 "issue",
1793 "list",
1794 "--limit",
1795 "100",
1796 "--json",
1797 ISSUE_LIST_FIELDS
1798 ]
1799 );
1800 assert_eq!(
1801 calls[2].args_str(),
1802 [
1803 "release",
1804 "list",
1805 "--limit",
1806 "100",
1807 "--json",
1808 RELEASE_LIST_FIELDS
1809 ]
1810 );
1811 }
1812
1813 // Without a base, `pr_create` must omit `--base` entirely. RecordingRunner
1814 // captures the exact invocation (and `&rec` plumbs through CliClient), so we
1815 // can assert flag *absence* and the cwd — which prefix matching can't.
1816 #[tokio::test]
1817 async fn pr_create_omits_base_when_none() {
1818 use processkit::testing::RecordingRunner;
1819 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
1820 let gh = GitHub::with_runner(&rec);
1821 let url = gh
1822 .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
1823 .await
1824 .expect("pr_create");
1825 assert_eq!(url, "https://gh/pr/2");
1826
1827 let call = rec.only_call();
1828 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
1829 assert_eq!(
1830 call.args_str(),
1831 ["pr", "create", "--title", "T", "--body", "B"]
1832 );
1833 assert!(!call.has_flag("--base"), "no base was given");
1834 assert!(!call.has_flag("--head"), "no head was given");
1835 }
1836
1837 // The injection guard on gh's exposed positionals.
1838 #[tokio::test]
1839 async fn flag_like_positionals_are_rejected_before_spawning() {
1840 let rec = RecordingRunner::replying(Reply::ok(""));
1841 let gh = GitHub::with_runner(&rec);
1842 assert!(gh.api(Path::new("."), "-evil").await.is_err());
1843 assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
1844 assert!(
1845 gh.api(Path::new("."), "").await.is_err(),
1846 "empty refused too"
1847 );
1848 assert!(rec.calls().is_empty(), "nothing may spawn");
1849 }
1850
1851 #[tokio::test]
1852 async fn api_runs_in_the_bound_repo_dir() {
1853 let rec = RecordingRunner::replying(Reply::ok("{}\n"));
1854 let gh = GitHub::with_runner(&rec);
1855 gh.api(Path::new("/repo"), "repos/o/r/pulls")
1856 .await
1857 .expect("api");
1858 let call = rec.only_call();
1859 assert_eq!(call.args_str(), ["api", "repos/o/r/pulls"]);
1860 // H9: the request runs in the bound repo dir, so gh resolves a relative
1861 // endpoint's `{owner}/{repo}` from *that* repo — not the process cwd.
1862 assert_eq!(call.cwd, Some(std::path::PathBuf::from("/repo")));
1863 }
1864
1865 // pr_merge builds the strategy flag plus the optional --auto/--delete-branch.
1866 #[tokio::test]
1867 async fn pr_merge_builds_strategy_and_flags() {
1868 let rec = RecordingRunner::replying(Reply::ok(""));
1869 let gh = GitHub::with_runner(&rec);
1870 gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
1871 .await
1872 .expect("pr_merge");
1873 assert_eq!(
1874 rec.only_call().args_str(),
1875 ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
1876 );
1877
1878 let bare = RecordingRunner::replying(Reply::ok(""));
1879 let gh = GitHub::with_runner(&bare);
1880 gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
1881 .await
1882 .expect("pr_merge");
1883 let call = bare.only_call();
1884 assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
1885 assert!(!call.has_flag("--auto"));
1886 assert!(!call.has_flag("--delete-branch"));
1887 }
1888
1889 #[tokio::test]
1890 async fn pr_mark_ready_and_close_build_args() {
1891 let rec = RecordingRunner::replying(Reply::ok(""));
1892 let gh = GitHub::with_runner(&rec);
1893 gh.pr_mark_ready(Path::new("/r"), 3)
1894 .await
1895 .expect("pr_mark_ready");
1896 gh.pr_close(Path::new("/r"), 3, PrClose::new().delete_branch())
1897 .await
1898 .expect("close");
1899 gh.pr_close(Path::new("/r"), 4, PrClose::new())
1900 .await
1901 .expect("close");
1902 let calls = rec.calls();
1903 assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
1904 assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
1905 assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
1906 }
1907
1908 // pr_checkout maps to `pr checkout <n>` and runs in the bound repo dir.
1909 #[tokio::test]
1910 async fn pr_checkout_builds_args_in_repo_dir() {
1911 let rec = RecordingRunner::replying(Reply::ok(""));
1912 let gh = GitHub::with_runner(&rec);
1913 gh.pr_checkout(Path::new("/repo"), 7)
1914 .await
1915 .expect("pr_checkout");
1916 let call = rec.only_call();
1917 assert_eq!(call.args_str(), ["pr", "checkout", "7"]);
1918 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
1919 // The bound view produces byte-identical argv.
1920 let rec = RecordingRunner::replying(Reply::ok(""));
1921 let gh = GitHub::with_runner(&rec);
1922 gh.at(Path::new("/repo"))
1923 .pr_checkout(7)
1924 .await
1925 .expect("pr_checkout");
1926 assert_eq!(rec.only_call().args_str(), ["pr", "checkout", "7"]);
1927 }
1928
1929 // gh signals the checks outcome via exit code (0 pass / 8 pending / 1 some
1930 // failed) but emits the same JSON for all three — all must parse. Other
1931 // exits (and timeouts) are genuine errors.
1932 #[tokio::test]
1933 async fn pr_checks_parses_all_outcome_exit_codes() {
1934 let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
1935 "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
1936 for reply in [
1937 Reply::ok(json),
1938 Reply::fail(8, "checks pending").with_stdout(json),
1939 Reply::fail(1, "some checks failed").with_stdout(json),
1940 ] {
1941 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
1942 let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
1943 assert_eq!(checks.len(), 1);
1944 assert_eq!(checks[0].bucket, CheckBucket::Pass);
1945 }
1946
1947 // A PR with no checks at all: gh exits 1 with NO JSON and a
1948 // "no checks reported" message — an empty list, not an error. Matched
1949 // case-insensitively, so a capitalized variant is still the empty case.
1950 for stderr in [
1951 "no checks reported on the 'feat/x' branch",
1952 "No Checks Reported on the 'feat/x' branch",
1953 ] {
1954 let gh = GitHub::with_runner(
1955 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
1956 );
1957 assert!(
1958 gh.pr_checks(Path::new("."), 7)
1959 .await
1960 .expect("no checks → empty")
1961 .is_empty(),
1962 "no-checks must read as empty for stderr {stderr:?}"
1963 );
1964 }
1965 // …while a bare exit 1 for a different reason stays an error.
1966 let gh = GitHub::with_runner(ScriptedRunner::new().on(
1967 ["gh", "pr", "checks"],
1968 Reply::fail(1, "no pull requests found for branch 'feat/x'"),
1969 ));
1970 assert!(matches!(
1971 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1972 Error::Exit { .. }
1973 ));
1974
1975 // Exit 4 (auth required) is a real failure, not an outcome.
1976 let gh = GitHub::with_runner(
1977 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
1978 );
1979 assert!(matches!(
1980 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1981 Error::Exit { .. }
1982 ));
1983
1984 let gh =
1985 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
1986 assert!(matches!(
1987 gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1988 Error::Timeout { .. }
1989 ));
1990 }
1991
1992 // Hermetic: real pr_diff() arg-building (incl. `--color never`) + the
1993 // shared unified-diff parser against canned `gh pr diff` output.
1994 #[tokio::test]
1995 async fn pr_diff_builds_args_and_parses_scripted_output() {
1996 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
1997 let rec = RecordingRunner::replying(Reply::ok(out));
1998 let gh = GitHub::with_runner(&rec);
1999 let files = gh.pr_diff(Path::new("/r"), 7).await.expect("pr_diff");
2000 assert_eq!(files.len(), 1);
2001 assert_eq!(files[0].path, std::path::Path::new("m"));
2002 assert_eq!(files[0].change, ChangeKind::Modified);
2003 assert_eq!(
2004 rec.only_call().args_str(),
2005 ["pr", "diff", "7", "--color", "never"]
2006 );
2007 }
2008
2009 // T-049: `pr_diff` over the client's default OutputBudget is refused with
2010 // `OutputTooLarge` (actual + allowed sizes), never a silently truncated diff.
2011 #[tokio::test]
2012 async fn pr_diff_over_budget_errors_output_too_large() {
2013 let big = "diff --git a/m b/m\n".to_string() + &"+line\n".repeat(20_000);
2014 assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
2015 let gh =
2016 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(&big)))
2017 .default_output_budget(OutputBudget::bytes(64 * 1024));
2018 match gh.pr_diff(Path::new("/r"), 7).await {
2019 Err(Error::OutputTooLarge {
2020 program,
2021 max_bytes,
2022 total_bytes,
2023 ..
2024 }) => {
2025 assert_eq!(program, "gh");
2026 assert_eq!(max_bytes, Some(64 * 1024));
2027 assert!(total_bytes > 64 * 1024, "actual exceeds allowed");
2028 }
2029 other => panic!("expected OutputTooLarge, got {other:?}"),
2030 }
2031 }
2032
2033 // The per-call override reads a legitimately large PR diff past the tight
2034 // client default that would otherwise refuse it.
2035 #[tokio::test]
2036 async fn pr_diff_within_override_reads_past_the_default() {
2037 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2038 let gh =
2039 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(out)))
2040 .default_output_budget(OutputBudget::bytes(4)); // absurdly tight default
2041 assert!(matches!(
2042 gh.pr_diff(Path::new("/r"), 7).await,
2043 Err(Error::OutputTooLarge { .. })
2044 ));
2045 let files = gh
2046 .pr_diff_within(Path::new("/r"), 7, OutputBudget::unlimited())
2047 .await
2048 .expect("override reads the diff");
2049 assert_eq!(files.len(), 1);
2050 assert_eq!(files[0].path, std::path::Path::new("m"));
2051 }
2052
2053 // T-049: `gh run watch`'s fixed cap is reconciled onto the shared OutputBudget
2054 // as its DROP-OLDEST *diagnostic* projection — a bounded tail that NEVER turns a
2055 // long, chatty watch into `OutputTooLarge`. A watch that reprints far past the
2056 // 256 KiB / 256-line cap still succeeds and reads the final run state.
2057 #[tokio::test]
2058 async fn run_watch_bounds_output_without_failing_loud() {
2059 // ~5 MiB of repeated job-table frames — well past the watch cap.
2060 let flood = "watching run… job A: running\n".repeat(180_000);
2061 let run_json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
2062 "status":"completed","conclusion":"success","workflowName":"CI",
2063 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
2064 let gh = GitHub::with_runner(
2065 ScriptedRunner::new()
2066 .on(["gh", "run", "watch"], Reply::ok(&flood))
2067 .on(["gh", "run", "view"], Reply::ok(run_json)),
2068 );
2069 // Must NOT error out with OutputTooLarge — the diagnostic projection drops
2070 // the oldest frames and keeps going, then `run view` yields the state.
2071 let run = gh
2072 .run_watch(Path::new("/r"), 42)
2073 .await
2074 .expect("a chatty watch is bounded, not failed loud");
2075 assert_eq!(run.database_id, 42);
2076 }
2077
2078 // Each review action maps to its flag; the body is carried on the action
2079 // (approve's is optional and omitted when absent).
2080 #[tokio::test]
2081 async fn pr_review_builds_action_args() {
2082 let rec = RecordingRunner::replying(Reply::ok(""));
2083 let gh = GitHub::with_runner(&rec);
2084 gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
2085 .await
2086 .expect("approve");
2087 gh.pr_review(
2088 Path::new("/r"),
2089 7,
2090 ReviewAction::request_changes("fix the parser"),
2091 )
2092 .await
2093 .expect("request changes");
2094 gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
2095 .await
2096 .expect("comment");
2097 let calls = rec.calls();
2098 assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
2099 assert!(!calls[0].has_flag("--body"));
2100 assert_eq!(
2101 calls[1].args_str(),
2102 [
2103 "pr",
2104 "review",
2105 "7",
2106 "--request-changes",
2107 "--body",
2108 "fix the parser"
2109 ]
2110 );
2111 assert_eq!(
2112 calls[2].args_str(),
2113 ["pr", "review", "7", "--comment", "--body", "nice"]
2114 );
2115 }
2116
2117 // `approve().with_body(..)` attaches the optional approve message, emitting
2118 // `--approve --body <body>`; the accessors read the parts back.
2119 #[tokio::test]
2120 async fn pr_review_approve_with_body() {
2121 let action = ReviewAction::approve().with_body("LGTM");
2122 assert_eq!(action.kind(), ReviewKind::Approve);
2123 assert_eq!(action.body(), Some("LGTM"));
2124
2125 let rec = RecordingRunner::replying(Reply::ok(""));
2126 let gh = GitHub::with_runner(&rec);
2127 gh.pr_review(Path::new("/r"), 7, action)
2128 .await
2129 .expect("approve with body");
2130 assert_eq!(
2131 rec.only_call().args_str(),
2132 ["pr", "review", "7", "--approve", "--body", "LGTM"]
2133 );
2134 }
2135
2136 #[tokio::test]
2137 async fn pr_comment_and_issue_create_return_urls() {
2138 let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
2139 let gh = GitHub::with_runner(&rec);
2140 assert_eq!(
2141 gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
2142 "https://gh/x"
2143 );
2144 assert_eq!(
2145 gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
2146 "https://gh/x"
2147 );
2148 let calls = rec.calls();
2149 assert_eq!(
2150 calls[0].args_str(),
2151 ["pr", "comment", "7", "--body", "hello"]
2152 );
2153 assert_eq!(
2154 calls[1].args_str(),
2155 ["issue", "create", "--title", "T", "--body", "B"]
2156 );
2157 }
2158
2159 // pr_edit emits only the flags the caller set. The flag-VALUE slots
2160 // (`--title <t>`, `--body <b>`) are passed verbatim — no argv-guard needed
2161 // since gh consumes the next token as a value, not as a flag.
2162 #[tokio::test]
2163 async fn pr_edit_emits_only_provided_fields() {
2164 let rec = RecordingRunner::replying(Reply::ok(""));
2165 let gh = GitHub::with_runner(&rec);
2166
2167 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
2168 .await
2169 .expect("title-only edit");
2170 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
2171 .await
2172 .expect("body-only edit");
2173 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
2174 .await
2175 .expect("both-fields edit");
2176
2177 let calls = rec.calls();
2178 assert_eq!(
2179 calls[0].args_str(),
2180 ["pr", "edit", "7", "--title", "New title"]
2181 );
2182 assert_eq!(
2183 calls[1].args_str(),
2184 ["pr", "edit", "7", "--body", "New body"]
2185 );
2186 assert_eq!(
2187 calls[2].args_str(),
2188 ["pr", "edit", "7", "--title", "T", "--body", "B"]
2189 );
2190 }
2191
2192 // An empty string is a real value (clears the field) — it must reach the
2193 // CLI as `--title ""`, not be silently dropped. The argv is asserted
2194 // byte-for-byte so a future "treat empty as None" regression would
2195 // surface here.
2196 #[tokio::test]
2197 async fn pr_edit_some_empty_string_clears_field() {
2198 let rec = RecordingRunner::replying(Reply::ok(""));
2199 let gh = GitHub::with_runner(&rec);
2200 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
2201 .await
2202 .expect("empty title");
2203 assert_eq!(
2204 rec.only_call().args_str(),
2205 ["pr", "edit", "7", "--title", ""]
2206 );
2207 }
2208
2209 #[tokio::test]
2210 async fn with_credentials_injects_gh_token_and_default_does_not() {
2211 // With a provider: the token is set as GH_TOKEN on the command — and never
2212 // appears in argv (so it can't leak through `ps`).
2213 let rec = RecordingRunner::replying(Reply::ok("[]"));
2214 let gh = GitHub::with_runner(&rec)
2215 .with_credentials(Arc::new(StaticCredential::token("tok-123")));
2216 gh.pr_list(Path::new("/r")).await.unwrap();
2217 let call = rec.only_call();
2218 let token = call
2219 .envs
2220 .iter()
2221 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
2222 .and_then(|(_, v)| v.as_ref())
2223 .and_then(|v| v.to_str());
2224 assert_eq!(
2225 token,
2226 Some("tok-123"),
2227 "provider token injected as GH_TOKEN"
2228 );
2229 assert!(
2230 !call.args_str().iter().any(|a| a.contains("tok-123")),
2231 "secret must never appear in argv"
2232 );
2233
2234 // Without a provider: no GH_TOKEN injected — ambient `gh` auth is unchanged.
2235 let rec = RecordingRunner::replying(Reply::ok("[]"));
2236 let gh = GitHub::with_runner(&rec);
2237 gh.pr_list(Path::new("/r")).await.unwrap();
2238 assert!(
2239 !rec.only_call()
2240 .envs
2241 .iter()
2242 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
2243 "no provider → no token env (ambient gh auth)"
2244 );
2245 }
2246
2247 // The `with_token` convenience is the common path: a static token, no `Arc`/
2248 // `StaticCredential` ceremony, injected as GH_TOKEN.
2249 #[tokio::test]
2250 async fn with_token_convenience_injects_gh_token() {
2251 let rec = RecordingRunner::replying(Reply::ok("[]"));
2252 let gh = GitHub::with_runner(&rec).with_token("tok-conv");
2253 gh.pr_list(Path::new("/r")).await.unwrap();
2254 let call = rec.only_call();
2255 let token = call
2256 .envs
2257 .iter()
2258 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
2259 .and_then(|(_, v)| v.as_ref())
2260 .and_then(|v| v.to_str());
2261 assert_eq!(token, Some("tok-conv"));
2262 }
2263
2264 // A provider that yields `Ok(None)` defers to ambient auth: no GH_TOKEN is
2265 // injected, exactly as if no provider were attached. Pins the None=ambient
2266 // contract end-to-end (not just at the provider level).
2267 #[tokio::test]
2268 async fn provider_returning_none_falls_back_to_ambient() {
2269 let rec = RecordingRunner::replying(Reply::ok("[]"));
2270 let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
2271 gh.pr_list(Path::new("/r")).await.unwrap();
2272 assert!(
2273 !rec.only_call()
2274 .envs
2275 .iter()
2276 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
2277 "Ok(None) provider injects no token (ambient)"
2278 );
2279 }
2280
2281 #[tokio::test]
2282 async fn injected_token_overrides_ambient_default_env() {
2283 // A provider token is applied after any `default_env("GH_TOKEN", …)`, so it
2284 // wins — "I supplied a provider, use it" beats an ambient env default.
2285 let rec = RecordingRunner::replying(Reply::ok("[]"));
2286 let gh = GitHub::with_runner(&rec)
2287 .default_env("GH_TOKEN", "ambient-token")
2288 .with_credentials(Arc::new(StaticCredential::token("provider-token")));
2289 gh.pr_list(Path::new("/r")).await.unwrap();
2290 let call = rec.only_call();
2291 let winner = call
2292 .envs
2293 .iter()
2294 .rev()
2295 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
2296 .and_then(|(_, v)| v.as_ref())
2297 .and_then(|v| v.to_str());
2298 assert_eq!(winner, Some("provider-token"), "provider token wins");
2299 }
2300
2301 // --- Enterprise host + host-scoped auth (T-046) ------------------------
2302
2303 // GitHubHost classifies github.com (any case) as SaaS and every other valid
2304 // host as GHES, canonicalizing to a lower-cased hostname.
2305 #[test]
2306 fn github_host_classifies_saas_and_enterprise() {
2307 let saas = GitHubHost::github_com();
2308 assert!(saas.is_github_com() && !saas.is_enterprise());
2309 assert_eq!(saas.as_str(), "github.com");
2310
2311 for h in ["github.com", "GitHub.com", "GITHUB.COM"] {
2312 let host = GitHubHost::new(h).unwrap();
2313 assert!(host.is_github_com(), "{h} should classify as SaaS");
2314 assert_eq!(host.as_str(), "github.com", "canonicalized to lower-case");
2315 }
2316
2317 let ghes = GitHubHost::new("GHE.Example.COM").unwrap();
2318 assert!(ghes.is_enterprise());
2319 assert_eq!(ghes.as_str(), "ghe.example.com");
2320 }
2321
2322 // A malformed hostname is a diagnosable invalid-input error, not a silent
2323 // github.com guess — so a bad host can't quietly become the SaaS default.
2324 #[test]
2325 fn github_host_new_rejects_malformed_hosts() {
2326 for bad in [
2327 "",
2328 " ",
2329 "-evil",
2330 "has space",
2331 "https://github.com",
2332 "github.com/owner",
2333 "ghe.example.com:8443",
2334 "user@github.com",
2335 ".leading",
2336 "trailing.",
2337 ] {
2338 let err = GitHubHost::new(bad).unwrap_err();
2339 assert!(
2340 vcs_cli_support::is_invalid_input(&err),
2341 "{bad:?} should be rejected as invalid input, got {err:?}"
2342 );
2343 }
2344 }
2345
2346 // from_remote_url derives + classifies the host across HTTPS / SSH / scp-like
2347 // remotes, dropping userinfo and port.
2348 #[test]
2349 fn github_host_from_remote_url_parses_and_classifies() {
2350 let cases = [
2351 ("https://github.com/o/r.git", "github.com", false),
2352 (
2353 "https://x-access-token:tok@ghe.example.com:8443/o/r",
2354 "ghe.example.com",
2355 true,
2356 ),
2357 ("http://ghe.internal.corp/o/r", "ghe.internal.corp", true),
2358 ("ssh://git@github.com/o/r", "github.com", false),
2359 ("ssh://git@ghe.example.com:22/o/r", "ghe.example.com", true),
2360 ("git@github.com:o/r.git", "github.com", false),
2361 ("git@ghe.example.com:o/r.git", "ghe.example.com", true),
2362 ];
2363 for (url, host, enterprise) in cases {
2364 let parsed =
2365 GitHubHost::from_remote_url(url).unwrap_or_else(|e| panic!("parse {url}: {e:?}"));
2366 assert_eq!(parsed.as_str(), host, "host for {url}");
2367 assert_eq!(parsed.is_enterprise(), enterprise, "class for {url}");
2368 }
2369 }
2370
2371 // An unparseable / hostless / ambiguous remote is a diagnosable error, never a
2372 // silent github.com fallback (which would authenticate the wrong host).
2373 #[test]
2374 fn github_host_from_remote_url_rejects_ambiguous() {
2375 for url in [
2376 "",
2377 " ",
2378 "not-a-url",
2379 "https://",
2380 "ssh://",
2381 "git@internalhost:repo.git",
2382 "C:\\repo\\path",
2383 "https://[::1]:8443/x",
2384 ] {
2385 let err = GitHubHost::from_remote_url(url).unwrap_err();
2386 assert!(
2387 vcs_cli_support::is_invalid_input(&err),
2388 "{url:?} should be a diagnosable error, got {err:?}"
2389 );
2390 }
2391 }
2392
2393 // Binding a github.com host injects the credential as GH_TOKEN (the SaaS
2394 // default) and pins GH_HOST — never the enterprise env.
2395 #[tokio::test]
2396 async fn with_host_github_com_injects_gh_token() {
2397 let rec = RecordingRunner::replying(Reply::ok("[]"));
2398 let gh = GitHub::with_runner(&rec)
2399 .with_host(GitHubHost::github_com())
2400 .with_token("saas-tok");
2401 gh.pr_list(Path::new("/r")).await.unwrap();
2402 let call = rec.only_call();
2403 assert!(call.env_is("GH_TOKEN", "saas-tok"));
2404 assert!(
2405 !call.has_env("GH_ENTERPRISE_TOKEN"),
2406 "github.com must not touch the enterprise token env"
2407 );
2408 assert!(call.env_is("GH_HOST", "github.com"));
2409 assert!(!call.args_str().iter().any(|a| a.contains("saas-tok")));
2410 }
2411
2412 // Binding a GHES host injects the credential as GH_ENTERPRISE_TOKEN — the env
2413 // gh reads for a non-github.com host — plus GH_HOST, and NEVER as GH_TOKEN, so
2414 // an enterprise secret can't leak into the github.com token env. The secret
2415 // stays out of argv.
2416 #[tokio::test]
2417 async fn with_host_enterprise_injects_enterprise_token_and_host() {
2418 let rec = RecordingRunner::replying(Reply::ok("[]"));
2419 let gh = GitHub::with_runner(&rec)
2420 .with_host(GitHubHost::new("ghe.example.com").unwrap())
2421 .with_token("ent-tok");
2422 gh.pr_list(Path::new("/r")).await.unwrap();
2423 let call = rec.only_call();
2424 assert!(call.env_is("GH_ENTERPRISE_TOKEN", "ent-tok"));
2425 assert!(
2426 !call.has_env("GH_TOKEN"),
2427 "enterprise token must not land in the github.com env"
2428 );
2429 assert!(call.env_is("GH_HOST", "ghe.example.com"));
2430 assert!(
2431 !call.args_str().iter().any(|a| a.contains("ent-tok")),
2432 "secret must never appear in argv"
2433 );
2434 }
2435
2436 // A host-bound client with NO provider injects no token at all (ambient gh
2437 // login for that host) but still pins GH_HOST, so gh targets the right server.
2438 #[tokio::test]
2439 async fn with_host_enterprise_without_credentials_is_ambient() {
2440 let rec = RecordingRunner::replying(Reply::ok("[]"));
2441 let gh = GitHub::with_runner(&rec).with_host(GitHubHost::new("ghe.corp.example").unwrap());
2442 gh.pr_list(Path::new("/r")).await.unwrap();
2443 let call = rec.only_call();
2444 assert!(!call.has_env("GH_ENTERPRISE_TOKEN"));
2445 assert!(!call.has_env("GH_TOKEN"));
2446 assert!(call.env_is("GH_HOST", "ghe.corp.example"));
2447 }
2448
2449 // Several hosts, one client each: every client injects only its own host's
2450 // token/env — a credential for one host never leaks into another.
2451 #[tokio::test]
2452 async fn multiple_hosts_inject_independently() {
2453 let rec_a = RecordingRunner::replying(Reply::ok("[]"));
2454 GitHub::with_runner(&rec_a)
2455 .with_host(GitHubHost::new("ghe.a.example").unwrap())
2456 .with_token("tok-a")
2457 .pr_list(Path::new("/r"))
2458 .await
2459 .unwrap();
2460
2461 let rec_b = RecordingRunner::replying(Reply::ok("[]"));
2462 GitHub::with_runner(&rec_b)
2463 .with_host(GitHubHost::new("ghe.b.example").unwrap())
2464 .with_token("tok-b")
2465 .pr_list(Path::new("/r"))
2466 .await
2467 .unwrap();
2468
2469 let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
2470 GitHub::with_runner(&rec_saas)
2471 .with_host(GitHubHost::github_com())
2472 .with_token("tok-saas")
2473 .pr_list(Path::new("/r"))
2474 .await
2475 .unwrap();
2476
2477 let ca = rec_a.only_call();
2478 assert!(ca.env_is("GH_ENTERPRISE_TOKEN", "tok-a") && ca.env_is("GH_HOST", "ghe.a.example"));
2479 assert!(
2480 !ca.args_str()
2481 .iter()
2482 .any(|s| s.contains("tok-b") || s.contains("tok-saas")),
2483 "host A must not carry another host's secret"
2484 );
2485
2486 let cb = rec_b.only_call();
2487 assert!(cb.env_is("GH_ENTERPRISE_TOKEN", "tok-b") && cb.env_is("GH_HOST", "ghe.b.example"));
2488
2489 let cs = rec_saas.only_call();
2490 assert!(cs.env_is("GH_TOKEN", "tok-saas") && cs.env_is("GH_HOST", "github.com"));
2491 assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
2492 }
2493
2494 // A HOST-KEYED provider on a host-bound client injects ONLY that host's secret,
2495 // into the env gh reads for it — and a client bound to a *different* host draws a
2496 // different secret from the SAME provider, so one instance's token never lands in
2497 // another's command. (T-045: the bound host now reaches the CredentialRequest, so
2498 // the provider can tell SaaS from a self-hosted GHES instance.)
2499 #[tokio::test]
2500 async fn host_keyed_provider_injects_only_the_bound_hosts_token() {
2501 // Typed as the trait object so `Arc::clone` yields `Arc<dyn …>` directly
2502 // (the unsized coercion doesn't flow back through `Arc::clone`'s inference).
2503 let provider: Arc<dyn CredentialProvider> =
2504 Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
2505 Ok(match r.host {
2506 Some("github.com") => Some(Credential::token("saas-secret")),
2507 Some("ghe.example.com") => Some(Credential::token("ent-secret")),
2508 _ => None,
2509 })
2510 }));
2511
2512 // SaaS client → GH_TOKEN carries the github.com secret, never the ent one.
2513 let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
2514 GitHub::with_runner(&rec_saas)
2515 .with_host(GitHubHost::github_com())
2516 .with_credentials(Arc::clone(&provider))
2517 .pr_list(Path::new("/r"))
2518 .await
2519 .unwrap();
2520 let cs = rec_saas.only_call();
2521 assert!(cs.env_is("GH_TOKEN", "saas-secret"));
2522 assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
2523 assert!(!cs.args_str().iter().any(|a| a.contains("saas-secret")));
2524
2525 // Enterprise client → the ENT secret in GH_ENTERPRISE_TOKEN only, from the
2526 // very same provider; the github.com token env is untouched.
2527 let rec_ent = RecordingRunner::replying(Reply::ok("[]"));
2528 GitHub::with_runner(&rec_ent)
2529 .with_host(GitHubHost::new("ghe.example.com").unwrap())
2530 .with_credentials(Arc::clone(&provider))
2531 .pr_list(Path::new("/r"))
2532 .await
2533 .unwrap();
2534 let ce = rec_ent.only_call();
2535 assert!(ce.env_is("GH_ENTERPRISE_TOKEN", "ent-secret"));
2536 assert!(
2537 !ce.has_env("GH_TOKEN"),
2538 "the enterprise command must not carry the github.com token env"
2539 );
2540 assert!(!ce.args_str().iter().any(|a| a.contains("ent-secret")));
2541 }
2542
2543 // Fallback policy, read vs write — `Ok(None)` (a host-keyed provider with nothing
2544 // for this host) leaves the command on ambient gh auth (no token env injected)
2545 // for BOTH a read (`pr_list`) and a write (`pr_merge`). (T-045)
2546 #[tokio::test]
2547 async fn provider_none_defers_to_ambient_for_read_and_write() {
2548 let rec_read = RecordingRunner::replying(Reply::ok("[]"));
2549 GitHub::with_runner(&rec_read)
2550 .with_host(GitHubHost::github_com())
2551 .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
2552 .pr_list(Path::new("/r"))
2553 .await
2554 .unwrap();
2555 let cr = rec_read.only_call();
2556 assert!(
2557 !cr.has_env("GH_TOKEN") && !cr.has_env("GH_ENTERPRISE_TOKEN"),
2558 "read defers to ambient on Ok(None)"
2559 );
2560
2561 let rec_write = RecordingRunner::replying(Reply::ok(""));
2562 GitHub::with_runner(&rec_write)
2563 .with_host(GitHubHost::github_com())
2564 .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
2565 .pr_merge(Path::new("/r"), 7, PrMerge::squash())
2566 .await
2567 .unwrap();
2568 let cw = rec_write.only_call();
2569 assert!(
2570 !cw.has_env("GH_TOKEN") && !cw.has_env("GH_ENTERPRISE_TOKEN"),
2571 "write defers to ambient on Ok(None)"
2572 );
2573 }
2574
2575 // Fallback policy, read vs write — a provider `Err` is FAIL-CLOSED: it aborts the
2576 // operation rather than silently running on ambient auth, proven separately for a
2577 // read (`pr_list`) and a write (`pr_merge`). gh is never spawned: the error
2578 // surfaces in `prepare`, before the process. (T-045)
2579 #[tokio::test]
2580 async fn provider_error_aborts_read_and_write_fail_closed() {
2581 fn boom() -> Arc<dyn CredentialProvider> {
2582 Arc::new(provider_fn(|_r: &CredentialRequest<'_>| {
2583 Err(Error::spawn(
2584 BINARY,
2585 std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
2586 ))
2587 }))
2588 }
2589
2590 let rec_read = RecordingRunner::replying(Reply::ok("[]"));
2591 let read = GitHub::with_runner(&rec_read)
2592 .with_host(GitHubHost::github_com())
2593 .with_credentials(boom())
2594 .pr_list(Path::new("/r"))
2595 .await;
2596 assert!(read.is_err(), "a provider error must abort the read");
2597 assert!(
2598 rec_read.calls().is_empty(),
2599 "gh must not spawn when the provider errored (read)"
2600 );
2601
2602 let rec_write = RecordingRunner::replying(Reply::ok(""));
2603 let write = GitHub::with_runner(&rec_write)
2604 .with_host(GitHubHost::github_com())
2605 .with_credentials(boom())
2606 .pr_merge(Path::new("/r"), 7, PrMerge::squash())
2607 .await;
2608 assert!(write.is_err(), "a provider error must abort the write");
2609 assert!(
2610 rec_write.calls().is_empty(),
2611 "gh must not spawn when the provider errored (write)"
2612 );
2613 }
2614
2615 // auth_status_for pins `--hostname <host>` and reflects the exit code as a bool.
2616 #[tokio::test]
2617 async fn auth_status_for_scopes_to_hostname() {
2618 let rec = RecordingRunner::replying(Reply::ok(""));
2619 let gh = GitHub::with_runner(&rec);
2620 let host = GitHubHost::new("ghe.example.com").unwrap();
2621 assert!(gh.auth_status_for(&host).await.unwrap());
2622 assert_eq!(
2623 rec.only_call().args_str(),
2624 ["auth", "status", "--hostname", "ghe.example.com"]
2625 );
2626 }
2627
2628 // The scoped probe reports the TARGET host truthfully even when a DIFFERENT
2629 // host's session is broken — no false negative from the aggregate `gh auth
2630 // status` that the unscoped `auth_status` would fold together.
2631 #[tokio::test]
2632 async fn auth_status_for_is_independent_of_other_host_sessions() {
2633 let runner = ScriptedRunner::new()
2634 .on(
2635 ["gh", "auth", "status", "--hostname", "broken.example.com"],
2636 Reply::fail(1, "not logged in to broken.example.com"),
2637 )
2638 .on(
2639 ["gh", "auth", "status", "--hostname", "good.example.com"],
2640 Reply::ok(""),
2641 );
2642 let gh = GitHub::with_runner(runner);
2643 assert!(
2644 gh.auth_status_for(&GitHubHost::new("good.example.com").unwrap())
2645 .await
2646 .unwrap(),
2647 "the healthy target host reads as authenticated"
2648 );
2649 assert!(
2650 !gh.auth_status_for(&GitHubHost::new("broken.example.com").unwrap())
2651 .await
2652 .unwrap(),
2653 "a broken host reads as not authenticated, independently"
2654 );
2655 }
2656
2657 // The bound view forwards auth_status_for verbatim (a bare, dir-independent
2658 // method): byte-identical argv, no cwd bound.
2659 #[tokio::test]
2660 async fn bound_view_auth_status_for_matches_client() {
2661 let rec = RecordingRunner::replying(Reply::ok(""));
2662 let gh = GitHub::with_runner(&rec);
2663 gh.at(Path::new("/repo"))
2664 .auth_status_for(&GitHubHost::github_com())
2665 .await
2666 .unwrap();
2667 let call = rec.only_call();
2668 assert_eq!(
2669 call.args_str(),
2670 ["auth", "status", "--hostname", "github.com"]
2671 );
2672 assert_eq!(call.cwd.as_deref(), None, "bare method binds no cwd");
2673 }
2674
2675 #[tokio::test]
2676 async fn pr_feedback_requests_reviews_and_comments() {
2677 let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
2678 "body":"","submittedAt":""}],"comments":[]}"#;
2679 let rec =
2680 RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
2681 let gh = GitHub::with_runner(&rec);
2682 let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
2683 assert_eq!(feedback.reviews[0].author, "a");
2684 assert!(feedback.comments.is_empty());
2685 assert_eq!(
2686 rec.only_call().args_str(),
2687 ["pr", "view", "7", "--json", "reviews,comments"]
2688 );
2689 }
2690
2691 // run_list appends --branch only when given one.
2692 #[tokio::test]
2693 async fn run_list_appends_branch_only_when_some() {
2694 let rec = RecordingRunner::replying(Reply::ok("[]"));
2695 let gh = GitHub::with_runner(&rec);
2696 gh.run_list(Path::new("/r"), 5, None).await.expect("list");
2697 gh.run_list(Path::new("/r"), 5, Some("main".into()))
2698 .await
2699 .expect("list");
2700 let calls = rec.calls();
2701 assert_eq!(
2702 calls[0].args_str(),
2703 ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
2704 );
2705 assert_eq!(
2706 calls[1].args_str(),
2707 [
2708 "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
2709 ]
2710 );
2711 }
2712
2713 // run_watch blocks on `run watch` (no `--exit-status`, so a failed run still
2714 // exits 0 — the outcome is read via the follow-up view, the only channel
2715 // that can distinguish failed from cancelled).
2716 #[tokio::test]
2717 async fn run_watch_then_views_final_state() {
2718 let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
2719 "status":"completed","conclusion":"failure","workflowName":"CI",
2720 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
2721 let rec = RecordingRunner::new(
2722 ScriptedRunner::new()
2723 .on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
2724 .on(["gh", "run", "view"], Reply::ok(json)),
2725 );
2726 let gh = GitHub::with_runner(&rec);
2727 let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
2728 assert_eq!(run.conclusion, "failure");
2729 let calls = rec.calls();
2730 assert_eq!(calls.len(), 2);
2731 assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
2732 assert_eq!(
2733 calls[1].args_str(),
2734 ["run", "view", "42", "--json", RUN_FIELDS]
2735 );
2736 }
2737
2738 // A timed-out or failing watch must error — NOT report a half-finished run
2739 // via the follow-up view. (`output_string` does not error on a timeout; the
2740 // `ensure_success` in run_watch is what surfaces it.)
2741 #[tokio::test]
2742 async fn run_watch_surfaces_timeout_and_watch_errors() {
2743 let rec = RecordingRunner::new(
2744 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
2745 );
2746 let gh = GitHub::with_runner(&rec);
2747 assert!(matches!(
2748 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
2749 Error::Timeout { .. }
2750 ));
2751 assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
2752
2753 let gh = GitHub::with_runner(
2754 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
2755 );
2756 assert!(matches!(
2757 gh.run_watch(Path::new("."), 42).await.unwrap_err(),
2758 Error::Exit { .. }
2759 ));
2760 }
2761
2762 // Client-level cancellation (processkit 0.8 `cancellation` feature): a client
2763 // built with `default_cancel_on(token)` threads the token into every command
2764 // it builds, so a long `run_watch` parks until the token fires, then surfaces
2765 // `Error::Cancelled` — a controller cancels without touching the call site
2766 // (zero new vcs-* API). Hermetic via `Reply::pending()` (parks until the
2767 // command's token fires) on a paused clock: the 1 h `timeout` elapses
2768 // instantly while the call is parked, proving it does not resolve early.
2769 #[tokio::test(start_paused = true)]
2770 async fn run_watch_cancels_via_client_default_token() {
2771 use processkit::CancellationToken;
2772 let token = CancellationToken::new();
2773 let gh =
2774 GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
2775 .default_cancel_on(token.clone());
2776 let call = gh.run_watch(Path::new("."), 42);
2777 tokio::pin!(call);
2778 assert!(
2779 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
2780 .await
2781 .is_err(),
2782 "run_watch must park until the token fires"
2783 );
2784 token.cancel();
2785 match call.await {
2786 Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
2787 other => panic!("expected Error::Cancelled, got {other:?}"),
2788 }
2789 }
2790
2791 #[tokio::test]
2792 async fn release_view_requests_view_fields() {
2793 let json = r#"{"tagName":"v1","name":"","body":"notes","url":"u",
2794 "publishedAt":"p","isDraft":false,"isPrerelease":false}"#;
2795 let rec = RecordingRunner::new(
2796 ScriptedRunner::new().on(["gh", "release", "view"], Reply::ok(json)),
2797 );
2798 let gh = GitHub::with_runner(&rec);
2799 let release = gh
2800 .release_view(Path::new("."), "v1")
2801 .await
2802 .expect("release_view");
2803 assert_eq!(release.tag_name, "v1");
2804 assert_eq!(release.body.as_deref(), Some("notes"));
2805 assert_eq!(release.url.as_deref(), Some("u"));
2806 assert_eq!(
2807 rec.only_call().args_str(),
2808 ["release", "view", "v1", "--json", RELEASE_VIEW_FIELDS]
2809 );
2810 }
2811
2812 // repo_view builds the --json request and flattens gh's nested owner/branch
2813 // objects into the public RepoView.
2814 #[tokio::test]
2815 async fn repo_view_parses_scripted_json() {
2816 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
2817 let gh =
2818 GitHub::with_runner(ScriptedRunner::new().on(["gh", "repo", "view"], Reply::ok(json)));
2819 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
2820 assert_eq!(repo.owner, "o");
2821 assert_eq!(repo.default_branch, "main");
2822 assert!(!repo.is_private);
2823 }
2824
2825 #[cfg(feature = "mock")]
2826 #[tokio::test]
2827 async fn consumer_mocks_the_interface() {
2828 let mut mock = MockGitHubApi::new();
2829 mock.expect_auth_status().returning(|| Ok(true));
2830 assert!(mock.auth_status().await.unwrap());
2831 }
2832}
2833
2834// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
2835#[doc = include_str!("../docs/github.md")]
2836#[allow(rustdoc::broken_intra_doc_links)]
2837pub mod guide {}