Skip to main content

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 {}