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 workflows and runs (list / view / watch,
19//! plus dispatch a workflow / rerun / cancel a run). One tiny call to 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`], [`Workflow`], [`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
58//!   workflows ([`workflow_list`](GitHubApi::workflow_list),
59//!   [`workflow_view`](GitHubApi::workflow_view)) and runs
60//!   ([`run_list`](GitHubApi::run_list), [`run_view`](GitHubApi::run_view),
61//!   [`run_watch`](GitHubApi::run_watch) — *blocking*, bounded by the client
62//!   timeout — plus the run-control verbs
63//!   [`workflow_dispatch`](GitHubApi::workflow_dispatch),
64//!   [`run_rerun`](GitHubApi::run_rerun), [`run_cancel`](GitHubApi::run_cancel));
65//!   issues & releases ([`issue_create`](GitHubApi::issue_create),
66//!   [`issue_close`](GitHubApi::issue_close), [`issue_reopen`](GitHubApi::issue_reopen),
67//!   [`issue_comment`](GitHubApi::issue_comment),
68//!   [`release_view`](GitHubApi::release_view), …); plus the escape hatches
69//!   [`run`](GitHubApi::run) / [`api`](GitHubApi::api) for anything unmodelled.
70//! - **Builder specs** for the multi-option commands — [`PrList`] / [`IssueList`]
71//!   select state and limit while the parameterless list methods remain open/100;
72//!   [`WorkflowList`] selects disabled inclusion and a limit while its shorthand
73//!   remains active-only/50;
74//!   [`PrCreate`] (title/body
75//!   with optional `head`/`base`), [`PrEdit`] (optional `title` and/or `body`
76//!   for `pr edit`), [`PrMerge`] (strategy [`MergeStrategy`],
77//!   `--auto`, `--delete-branch`), [`PrClose`] (optional `--delete-branch`),
78//!   [`WorkflowDispatch`] (a `workflow_dispatch` event's target `ref` + inputs),
79//!   [`ReleaseCreate`] (a release's title/notes/draft/prerelease), and
80//!   [`ReviewAction`] (whose private fields make
81//!   an empty-body request-changes unrepresentable) — each `#[non_exhaustive]`,
82//!   built with a constructor and chained setters, named after the flags they emit.
83//!   A single-toggle verb takes a direct argument instead ([`run_rerun`](GitHubApi::run_rerun)'s
84//!   [`RerunScope`]).
85//!
86//! # Recipes
87//!
88//! Read state — depend on the trait so the same code takes a real client or a mock:
89//!
90//! ```no_run
91//! use std::path::Path;
92//! use vcs_github::{GitHub, GitHubApi};
93//! # async fn demo() -> Result<(), processkit::Error> {
94//! let gh = GitHub::new();
95//! let dir = Path::new(".");
96//! let authed = gh.auth_status().await?;          // is `gh` logged in?
97//! let open = gh.pr_list(dir).await?;             // up to 100 open PRs
98//! # let _ = (authed, open); Ok(()) }
99//! ```
100//!
101//! Mutate through the builder specs — open a PR, approve it, then squash-merge:
102//!
103//! ```no_run
104//! use std::path::Path;
105//! use vcs_github::{GitHub, GitHubApi, PrCreate, PrMerge, ReviewAction};
106//! # async fn demo(gh: &GitHub) -> Result<(), processkit::Error> {
107//! let dir = Path::new(".");
108//! let url = gh.pr_create(dir, PrCreate::new("Add X", "…").base("main")).await?;
109//! gh.pr_review(dir, 7, ReviewAction::approve().with_body("LGTM")).await?;
110//! gh.pr_merge(dir, 7, PrMerge::squash().delete_branch()).await?;
111//! # let _ = url; Ok(()) }
112//! ```
113//!
114//! # Testing
115//!
116//! Two seams: enable the **`mock`** feature for a `mockall`-generated
117//! `MockGitHubApi` (stub whole methods), or inject a
118//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`GitHub::with_runner`]
119//! to exercise the *real* argv-building and parsing against canned output — no
120//! `gh` binary or network needed, so it runs on CI. The cross-cutting testing
121//! patterns live in
122//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
123//!
124//! # Safety
125//!
126//! Caller values placed in a bare positional argv slot (an `api` endpoint, a
127//! release `tag`) are refused before spawning if empty or starting with `-` —
128//! `gh` would parse them as flags. Flag-value slots (`--body <b>`,
129//! `--branch <b>`) are consumed verbatim and need no guard.
130//!
131//! # In-depth guide
132//!
133//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
134//! from `docs/`. See the [`guide`] module.
135
136use std::path::Path;
137use std::sync::Arc;
138use std::time::Duration;
139
140// The credential seam (the shared managed client behind `GitHub` is generated by
141// `vcs_cli_support::managed_client!`) — re-exported so a consumer can supply a
142// token provider.
143pub use vcs_cli_support::{
144    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
145    OutputBudget, Secret, StaticCredential, provider_fn,
146};
147// Re-export the processkit types in this crate's public API, so consumers needn't
148// depend on processkit directly — incl. `ProcessRunner` (the `with_runner`/
149// `GitHub<R>` seam) and the `JobRunner` default. (Also brings
150// `Error`/`Result`/`ProcessResult`/`ProcessRunner` into scope here.)
151// `ErrorReason` and `ErrorKind` ride along deliberately: since processkit 3.0
152// `Error` is an opaque wrapper, so *classifying* a failure means reaching
153// `err.reason()` (variant-grain) or `err.kind()` (flat) — types a consumer cannot
154// name without them. Omitting them would leave the re-exported `Error` unmatched,
155// a silent capability regression rather than a mechanical rename.
156pub use processkit::{
157    Error, ErrorKind, ErrorReason, JobRunner, ProcessResult, ProcessRunner, Result,
158};
159// Re-exported so a consumer can name the token for `default_cancel_on` without
160// taking a direct `processkit` dependency. (Cancellation is core in processkit
161// 0.10 — always available, no feature.)
162pub use processkit::CancellationToken;
163
164mod parse;
165pub use parse::{
166    CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, RepoView, Review,
167    Workflow, WorkflowRun,
168};
169// Re-exported so `vcs_github::FileDiff` (and the types nested in it) resolve
170// without a direct `vcs-diff` dependency — `pr_diff` returns `vcs-diff`'s model
171// verbatim (`gh pr diff` emits the same git-format diff `git diff`/`jj diff
172// --git` do; `crates/diff/src/diff.rs`'s parser is shared, not duplicated).
173pub use vcs_diff::{ChangeKind, DiffLine, FileDiff, Hunk};
174// The parsed `gh --version`, re-exported as `GitHubVersion` — the shared
175// `major.minor.patch` type `vcs-git`/`vcs-jj` also gate on (an alias of
176// `vcs_diff::Version`), so a consumer needn't name `vcs-diff` to read
177// [`GitHubCapabilities::version`].
178pub use vcs_diff::Version as GitHubVersion;
179
180/// Which pull requests [`GitHubApi::pr_list_with`] returns.
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum PrListState {
184    /// Open pull requests (the CLI default).
185    #[default]
186    Open,
187    /// Closed, unmerged pull requests.
188    Closed,
189    /// Merged pull requests.
190    Merged,
191    /// Pull requests in every state.
192    All,
193}
194
195impl PrListState {
196    fn as_arg(self) -> &'static str {
197        match self {
198            Self::Open => "open",
199            Self::Closed => "closed",
200            Self::Merged => "merged",
201            Self::All => "all",
202        }
203    }
204}
205
206/// Filters for [`GitHubApi::pr_list_with`] (`gh pr list`).
207#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub struct PrList {
210    /// State filter (`--state`).
211    pub state: PrListState,
212    /// Maximum number of pull requests (`--limit`).
213    pub limit: usize,
214}
215
216impl PrList {
217    /// Open pull requests, up to 100 — the compatibility default used by
218    /// [`GitHubApi::pr_list`].
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    /// Select a pull-request state.
224    pub fn state(mut self, state: PrListState) -> Self {
225        self.state = state;
226        self
227    }
228
229    /// Set the maximum number of pull requests returned.
230    pub fn limit(mut self, limit: usize) -> Self {
231        self.limit = limit;
232        self
233    }
234}
235
236impl Default for PrList {
237    fn default() -> Self {
238        Self {
239            state: PrListState::Open,
240            limit: 100,
241        }
242    }
243}
244
245/// Which issues [`GitHubApi::issue_list_with`] returns.
246#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
247#[non_exhaustive]
248pub enum IssueListState {
249    /// Open issues (the CLI default).
250    #[default]
251    Open,
252    /// Closed issues.
253    Closed,
254    /// Issues in every state.
255    All,
256}
257
258impl IssueListState {
259    fn as_arg(self) -> &'static str {
260        match self {
261            Self::Open => "open",
262            Self::Closed => "closed",
263            Self::All => "all",
264        }
265    }
266}
267
268/// Filters for [`GitHubApi::issue_list_with`] (`gh issue list`).
269#[derive(Debug, Clone, PartialEq, Eq)]
270#[non_exhaustive]
271pub struct IssueList {
272    /// State filter (`--state`).
273    pub state: IssueListState,
274    /// Maximum number of issues (`--limit`).
275    pub limit: usize,
276}
277
278impl IssueList {
279    /// Open issues, up to 100 — the compatibility default used by
280    /// [`GitHubApi::issue_list`].
281    pub fn new() -> Self {
282        Self::default()
283    }
284
285    /// Select an issue state.
286    pub fn state(mut self, state: IssueListState) -> Self {
287        self.state = state;
288        self
289    }
290
291    /// Set the maximum number of issues returned.
292    pub fn limit(mut self, limit: usize) -> Self {
293        self.limit = limit;
294        self
295    }
296}
297
298impl Default for IssueList {
299    fn default() -> Self {
300        Self {
301            state: IssueListState::Open,
302            limit: 100,
303        }
304    }
305}
306
307/// Filters for [`GitHubApi::workflow_list_with`] (`gh workflow list`).
308#[derive(Debug, Clone, PartialEq, Eq)]
309#[non_exhaustive]
310pub struct WorkflowList {
311    /// Include disabled workflows (`--all`). Disabled workflows are hidden by
312    /// default by `gh`.
313    pub include_disabled: bool,
314    /// Maximum number of workflows (`--limit`).
315    pub limit: usize,
316}
317
318impl WorkflowList {
319    /// Active workflows, up to gh's default of 50 — the compatibility default
320    /// used by [`GitHubApi::workflow_list`].
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Include disabled workflows (`--all`).
326    pub fn all(mut self) -> Self {
327        self.include_disabled = true;
328        self
329    }
330
331    /// Set the maximum number of workflows returned.
332    pub fn limit(mut self, limit: usize) -> Self {
333        self.limit = limit;
334        self
335    }
336}
337
338impl Default for WorkflowList {
339    fn default() -> Self {
340        Self {
341            include_disabled: false,
342            limit: 50,
343        }
344    }
345}
346
347/// Name of the underlying CLI binary this crate drives.
348pub const BINARY: &str = "gh";
349
350const PR_FIELDS: &str = "number,title,state,isDraft,headRefName,baseRefName,url,labels,assignees,author,createdAt,updatedAt,milestone";
351const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
352const ISSUE_LIST_FIELDS: &str =
353    "number,title,state,body,url,labels,assignees,author,createdAt,updatedAt,milestone";
354const ISSUE_VIEW_FIELDS: &str =
355    "number,title,state,body,url,labels,assignees,author,createdAt,updatedAt,milestone";
356const RUN_FIELDS: &str =
357    "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
358const WORKFLOW_FIELDS: &str = "id,name,path,state";
359// `gh workflow view` has no JSON mode. Resolve a typed view through the JSON
360// inventory instead; this signed-32-bit maximum asks gh to paginate until the
361// repository is exhausted without overflowing gh's `int` on 32-bit builds.
362const WORKFLOW_VIEW_LOOKUP_LIMIT: usize = i32::MAX as usize;
363// `gh run watch` refreshes its table about every three seconds. Five minutes without
364// either stream progressing therefore signals a wedged watcher, while still allowing
365// an otherwise healthy CI run to last for hours.
366const RUN_WATCH_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(5 * 60);
367const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
368const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
369const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease,author";
370
371/// Injection guard for bare positional argv slots: a caller-supplied value
372/// with a leading `-` is parsed by gh's CLI as a *flag* (verified: `gh api -evil` →
373/// flag parsing), and an empty value changes a command's
374/// meaning. Refuse both before anything spawns. Most flag-VALUE positions
375/// (`--body <b>`, `--branch <b>`) need no guard because gh consumes the next
376/// token verbatim; public PR list filters additionally use this guard as a
377/// defense-in-depth boundary for untrusted branch input.
378fn reject_flag_like(what: &str, value: &str) -> Result<()> {
379    vcs_cli_support::reject_flag_like(BINARY, what, value)
380}
381
382fn reject_zero_limit(operation: &str, limit: usize) -> Result<()> {
383    if limit == 0 {
384        return Err(Error::spawn(
385            BINARY,
386            std::io::Error::new(
387                std::io::ErrorKind::InvalidInput,
388                format!("{operation} limit must be greater than zero"),
389            ),
390        ));
391    }
392    Ok(())
393}
394
395/// Reject a label mutation that cannot change anything, or a label name the CLI
396/// cannot resolve. Label names otherwise stay unfiltered: they are always passed
397/// in a flag-value slot, so a leading `-` is data rather than another option.
398fn reject_invalid_labels(operation: &str, labels: &[String]) -> Result<()> {
399    if labels.is_empty() || labels.iter().any(|label| label.trim().is_empty()) {
400        return Err(Error::spawn(
401            BINARY,
402            std::io::Error::new(
403                std::io::ErrorKind::InvalidInput,
404                format!("{operation} requires at least one non-empty label"),
405            ),
406        ));
407    }
408    Ok(())
409}
410
411/// Reject workflow-dispatch input keys that would make gh parse a different
412/// `key=value` pair, or which cannot be passed to a process. Values intentionally
413/// remain unconstrained: `--raw-field` receives them as literal flag-value data.
414fn reject_invalid_workflow_dispatch_fields(fields: &[(String, String)]) -> Result<()> {
415    for (key, _) in fields {
416        let reason = if key.trim().is_empty() {
417            "must not be empty"
418        } else if key.contains('=') {
419            "must not contain `=`"
420        } else if key.contains('\0') {
421            "must not contain NUL"
422        } else {
423            continue;
424        };
425        return Err(Error::spawn(
426            BINARY,
427            std::io::Error::new(
428                std::io::ErrorKind::InvalidInput,
429                format!("workflow_dispatch input key {key:?} {reason}"),
430            ),
431        ));
432    }
433    Ok(())
434}
435
436fn resolve_workflow(workflows: Vec<Workflow>, selector: &str) -> Result<Workflow> {
437    if selector.is_empty() {
438        return Err(Error::spawn(
439            BINARY,
440            std::io::Error::new(
441                std::io::ErrorKind::InvalidInput,
442                "workflow_view selector must not be empty",
443            ),
444        ));
445    }
446
447    let numeric_id = selector.parse::<u64>().ok();
448    let selector_lower = selector.to_lowercase();
449    let is_file = selector_lower.ends_with(".yml") || selector_lower.ends_with(".yaml");
450    let mut matches: Vec<_> = workflows
451        .into_iter()
452        .filter(|workflow| {
453            if let Some(id) = numeric_id {
454                workflow.id == id
455            } else if is_file {
456                workflow.path == selector
457                    || workflow
458                        .path
459                        .rsplit('/')
460                        .next()
461                        .is_some_and(|file| file == selector)
462            } else {
463                workflow.name.to_lowercase() == selector_lower
464            }
465        })
466        .collect();
467
468    match matches.len() {
469        1 => Ok(matches.pop().expect("length checked")),
470        0 => Err(Error::parse(
471            BINARY,
472            format!("could not find workflow {selector:?}"),
473        )),
474        count => Err(Error::parse(
475            BINARY,
476            format!("workflow selector {selector:?} is ambiguous ({count} matches)"),
477        )),
478    }
479}
480
481/// The GitHub host an operation targets: SaaS `github.com` or a **GitHub
482/// Enterprise Server** (GHES) host. `gh` picks the credential environment variable
483/// it reads *per host* — `GH_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN` for a
484/// GHES host — and its `auth status` can be scoped to a single host, so this type
485/// carries that host so the client (1) injects a supplied credential into the
486/// variable `gh` actually reads for it (see [`GitHub::with_host`]) and (2) can
487/// probe auth for exactly that host (see [`GitHubApi::auth_status_for`]).
488///
489/// Build it for github.com ([`github_com`](GitHubHost::github_com)), from a bare
490/// hostname ([`new`](GitHubHost::new)), or from a repository's remote URL
491/// ([`from_remote_url`](GitHubHost::from_remote_url)). A hostname that cannot be
492/// determined is an **error**, never a silent fall back to github.com — so an
493/// ambiguous or unknown host is a diagnosable result at the call site rather than
494/// a quiet authentication against the wrong host with the github.com token.
495///
496/// ```
497/// # use vcs_github::GitHubHost;
498/// let saas = GitHubHost::github_com();
499/// assert!(saas.is_github_com() && !saas.is_enterprise());
500///
501/// let ghes = GitHubHost::new("ghe.example.com").unwrap();
502/// assert!(ghes.is_enterprise());
503/// assert_eq!(ghes.as_str(), "ghe.example.com");
504///
505/// // github.com (any case) classifies as SaaS; every other valid host is GHES.
506/// assert!(GitHubHost::new("GitHub.com").unwrap().is_github_com());
507/// // An unparseable / hostless remote is an error, not a github.com guess.
508/// assert!(GitHubHost::from_remote_url("not-a-url").is_err());
509/// ```
510#[derive(Clone, Debug, PartialEq, Eq)]
511pub struct GitHubHost {
512    /// The canonical (lower-cased) hostname, e.g. `github.com` / `ghe.example.com`.
513    host: String,
514    /// `true` for a GitHub Enterprise Server host; `false` for SaaS github.com.
515    enterprise: bool,
516}
517
518impl GitHubHost {
519    /// The SaaS GitHub hostname (`github.com`).
520    pub const SAAS_HOST: &'static str = "github.com";
521
522    /// The SaaS github.com host — a supplied credential is injected as `GH_TOKEN`.
523    #[must_use]
524    pub fn github_com() -> Self {
525        Self {
526            host: Self::SAAS_HOST.to_string(),
527            enterprise: false,
528        }
529    }
530
531    /// Classify a bare `host`: `github.com` (case-insensitive) is SaaS; any other
532    /// valid hostname is treated as a GitHub Enterprise Server host (its credential
533    /// goes to `GH_ENTERPRISE_TOKEN`). Returns an error for an empty, flag-like, or
534    /// otherwise malformed hostname (a scheme, path, port, userinfo, or whitespace)
535    /// rather than guessing — the value must be a bare DNS-style host.
536    pub fn new(host: impl AsRef<str>) -> Result<Self> {
537        let host = validate_host(host.as_ref())?;
538        let enterprise = host != Self::SAAS_HOST;
539        Ok(Self { host, enterprise })
540    }
541
542    /// Derive the host from a repository **remote URL** and classify it. Handles
543    /// `scheme://[user@]host[:port]/…` (HTTPS/SSH/…) and the scp-like
544    /// `[user@]host:path` SSH form; any userinfo and port are dropped. A remote
545    /// whose host can't be determined (unparseable, hostless, or ambiguous — an
546    /// IPv6 literal, a bare single-label scp authority, a local path) is an
547    /// **error**, not a silent github.com fallback, so the caller can surface an
548    /// ambiguous remote as a diagnosable result.
549    pub fn from_remote_url(url: &str) -> Result<Self> {
550        match host_from_remote_url(url) {
551            Some(host) => Self::new(host),
552            None => Err(invalid_host_error(
553                url,
554                "no GitHub host could be determined from the remote URL",
555            )),
556        }
557    }
558
559    /// The canonical hostname (`github.com`, `ghe.example.com`).
560    #[must_use]
561    pub fn as_str(&self) -> &str {
562        &self.host
563    }
564
565    /// Whether this is a GitHub Enterprise Server host (anything but github.com).
566    #[must_use]
567    pub fn is_enterprise(&self) -> bool {
568        self.enterprise
569    }
570
571    /// Whether this is SaaS github.com.
572    #[must_use]
573    pub fn is_github_com(&self) -> bool {
574        !self.enterprise
575    }
576
577    /// The environment variable `gh` reads for a credential on this host —
578    /// `GH_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN` for a GHES host. `'static`
579    /// so it can seed the client's token-env binding.
580    fn token_env_var(&self) -> &'static str {
581        if self.enterprise {
582            "GH_ENTERPRISE_TOKEN"
583        } else {
584            "GH_TOKEN"
585        }
586    }
587}
588
589/// Validate a bare gh hostname, returning it **lower-cased** (its canonical form —
590/// hostnames are case-insensitive and `gh` stores them lower-cased). A host must
591/// be a non-empty DNS-style name (ASCII letters/digits/`.`/`-`), not start with
592/// `-`/`.` nor end with `.`, and carry no scheme, path, port, userinfo, or
593/// whitespace. Anything else is refused as invalid input — `gh` would misread it,
594/// or it is not a host at all.
595fn validate_host(host: &str) -> Result<String> {
596    let trimmed = host.trim();
597    let well_formed = !trimmed.is_empty()
598        && !trimmed.starts_with('-')
599        && !trimmed.starts_with('.')
600        && !trimmed.ends_with('.')
601        && trimmed
602            .chars()
603            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
604    if !well_formed {
605        return Err(invalid_host_error(host, "not a valid GitHub hostname"));
606    }
607    Ok(trimmed.to_ascii_lowercase())
608}
609
610/// The `ErrorReason::Spawn` / `InvalidInput` the crate raises for a rejected caller
611/// value (the same shape as [`reject_flag_like`], classified by
612/// `vcs_cli_support::is_invalid_input`), naming the bad host and why.
613fn invalid_host_error(value: &str, reason: &str) -> Error {
614    Error::spawn(
615        BINARY,
616        std::io::Error::new(
617            std::io::ErrorKind::InvalidInput,
618            format!("GitHub host {value:?}: {reason}"),
619        ),
620    )
621}
622
623/// Extract the hostname from a repository remote URL (HTTPS / SSH / scp-like),
624/// dropping any userinfo and port. Returns `None` when no unambiguous host is
625/// present, so [`GitHubHost::from_remote_url`] surfaces a diagnosable error rather
626/// than defaulting to github.com. An IPv6-literal authority (`[::1]`) and a bare
627/// single-label scp authority (indistinguishable from a Windows drive path) return
628/// `None` too — a GitHub host is a dotted DNS name.
629fn host_from_remote_url(url: &str) -> Option<String> {
630    let url = url.trim();
631    if url.is_empty() {
632        return None;
633    }
634    // scheme://[user@]host[:port]/…  (https, http, ssh, git, …). The authority
635    // ends at the first `/`, `?`, or `#`; drop any `user:pass@` userinfo.
636    if let Some((_scheme, rest)) = url.split_once("://") {
637        let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
638        let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
639        return strip_port(host_port);
640    }
641    // scp-like SSH: `[user@]host:path` (no scheme). The host ends at the first `:`.
642    if let Some((authority, _path)) = url.split_once(':') {
643        let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
644        // Require a dotted host so a Windows drive path (`C:\…`) or a bare
645        // single-label authority isn't misread as a remote host — those are
646        // ambiguous, and the caller gets a diagnosable error instead of a guess.
647        if host.contains('.') && !host.contains('/') && !host.contains('\\') {
648            return Some(host.to_string());
649        }
650    }
651    None
652}
653
654/// Drop a trailing `:port` from `host[:port]`, refusing an IPv6-literal authority
655/// (`[::1]`) — a GitHub host is never a bracketed literal, and gh names hosts
656/// without a port.
657fn strip_port(host_port: &str) -> Option<String> {
658    if host_port.is_empty() || host_port.starts_with('[') {
659        return None;
660    }
661    Some(
662        host_port
663            .split_once(':')
664            .map_or(host_port, |(h, _)| h)
665            .to_string(),
666    )
667}
668
669/// How [`GitHubApi::pr_merge`] merges the PR — exactly one of gh's mutually
670/// exclusive strategy flags.
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672#[non_exhaustive]
673pub enum MergeStrategy {
674    /// A merge commit (`--merge`).
675    Merge,
676    /// Squash into one commit (`--squash`).
677    Squash,
678    /// Rebase the commits onto the base (`--rebase`).
679    Rebase,
680}
681
682impl MergeStrategy {
683    fn flag(self) -> &'static str {
684        match self {
685            MergeStrategy::Merge => "--merge",
686            MergeStrategy::Squash => "--squash",
687            MergeStrategy::Rebase => "--rebase",
688        }
689    }
690}
691
692/// Options for [`GitHubApi::pr_merge`] (`gh pr merge`).
693///
694/// `#[non_exhaustive]`, so build it through the strategy constructors —
695/// [`merge`](PrMerge::merge) / [`squash`](PrMerge::squash) /
696/// [`rebase`](PrMerge::rebase), then [`auto`](PrMerge::auto) /
697/// [`delete_branch`](PrMerge::delete_branch) — rather than a struct literal.
698#[derive(Debug, Clone)]
699#[non_exhaustive]
700pub struct PrMerge {
701    /// The merge strategy (exactly one of gh's `--merge`/`--squash`/`--rebase`).
702    pub strategy: MergeStrategy,
703    /// Enable auto-merge: merge once requirements are met (`--auto`).
704    pub auto: bool,
705    /// Delete the head branch after the merge (`--delete-branch`).
706    pub delete_branch: bool,
707}
708
709impl PrMerge {
710    /// Merge with a merge commit (`gh pr merge --merge`).
711    pub fn merge() -> Self {
712        Self::with(MergeStrategy::Merge)
713    }
714
715    /// Squash-merge (`gh pr merge --squash`).
716    pub fn squash() -> Self {
717        Self::with(MergeStrategy::Squash)
718    }
719
720    /// Rebase-merge (`gh pr merge --rebase`).
721    pub fn rebase() -> Self {
722        Self::with(MergeStrategy::Rebase)
723    }
724
725    fn with(strategy: MergeStrategy) -> Self {
726        Self {
727            strategy,
728            auto: false,
729            delete_branch: false,
730        }
731    }
732
733    /// Merge automatically once requirements are met (`--auto`).
734    pub fn auto(mut self) -> Self {
735        self.auto = true;
736        self
737    }
738
739    /// Delete the head branch after merging (`--delete-branch`).
740    pub fn delete_branch(mut self) -> Self {
741        self.delete_branch = true;
742        self
743    }
744}
745
746/// Options for [`GitHubApi::pr_close`] (`gh pr close`).
747///
748/// `#[non_exhaustive]`, so build it through [`PrClose::new`] and the chained
749/// [`delete_branch`](PrClose::delete_branch) setter rather than a bare `bool`
750/// (`pr_close(n, true)` doesn't say what `true` does).
751#[derive(Debug, Clone, Default, PartialEq, Eq)]
752#[non_exhaustive]
753pub struct PrClose {
754    /// Delete the head branch after closing the PR (`--delete-branch`).
755    pub delete_branch: bool,
756}
757
758impl PrClose {
759    /// Close the PR, leaving the head branch in place.
760    pub fn new() -> Self {
761        Self::default()
762    }
763
764    /// Delete the head branch after closing (`--delete-branch`).
765    pub fn delete_branch(mut self) -> Self {
766        self.delete_branch = true;
767        self
768    }
769}
770
771/// Options for [`GitHubApi::pr_create`] (`gh pr create`).
772///
773/// `#[non_exhaustive]`, so build it through [`PrCreate::new`] (title + body)
774/// and the chained [`head`](PrCreate::head) / [`base`](PrCreate::base) setters
775/// rather than a struct literal.
776#[derive(Debug, Clone)]
777#[non_exhaustive]
778pub struct PrCreate {
779    /// The PR title (`--title`).
780    pub title: String,
781    /// The PR body (`--body`).
782    pub body: String,
783    /// The source branch (`--head`); `None` = the current branch.
784    pub head: Option<String>,
785    /// The target branch (`--base`); `None` = the repo default.
786    pub base: Option<String>,
787    /// Labels to apply (`--label <name>`, repeated).
788    pub labels: Vec<String>,
789}
790
791impl PrCreate {
792    /// A PR with the given title and body, opened from the current branch into
793    /// the repo default (`gh pr create --title <title> --body <body>`).
794    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
795        Self {
796            title: title.into(),
797            body: body.into(),
798            head: None,
799            base: None,
800            labels: Vec::new(),
801        }
802    }
803
804    /// Set the source branch (`--head`).
805    pub fn head(mut self, head: impl Into<String>) -> Self {
806        self.head = Some(head.into());
807        self
808    }
809
810    /// Set the target branch (`--base`).
811    pub fn base(mut self, base: impl Into<String>) -> Self {
812        self.base = Some(base.into());
813        self
814    }
815
816    /// Apply these labels when opening the pull request.
817    pub fn labels(mut self, labels: impl Into<Vec<String>>) -> Self {
818        self.labels = labels.into();
819        self
820    }
821}
822
823/// Options for [`GitHubApi::issue_create_with`] (`gh issue create`).
824#[derive(Debug, Clone, PartialEq, Eq)]
825#[non_exhaustive]
826pub struct IssueCreate {
827    /// The issue title (`--title`).
828    pub title: String,
829    /// The issue body (`--body`).
830    pub body: String,
831    /// Labels to apply (`--label <name>`, repeated).
832    pub labels: Vec<String>,
833}
834
835impl IssueCreate {
836    /// An issue with no labels.
837    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
838        Self {
839            title: title.into(),
840            body: body.into(),
841            labels: Vec::new(),
842        }
843    }
844
845    /// Apply these labels when opening the issue.
846    pub fn labels(mut self, labels: impl Into<Vec<String>>) -> Self {
847        self.labels = labels.into();
848        self
849    }
850}
851
852/// Options for [`GitHubApi::pr_edit`] (`gh pr edit`).
853///
854/// `#[non_exhaustive]`, so build it through [`PrEdit::new`] and the chained
855/// [`title`](PrEdit::title) / [`body`](PrEdit::body) setters rather than a
856/// struct literal. At least one of `title` or `body` must be `Some`; both
857/// `None` is rejected by the facade before spawning (an explicit error, not a
858/// silent no-op). An empty string is a real value — gh clears the field on
859/// `--title ""` / `--body ""` — not a `None`.
860#[derive(Debug, Clone, PartialEq, Eq)]
861#[non_exhaustive]
862pub struct PrEdit {
863    /// The new title (`--title`); `None` leaves the title alone.
864    pub title: Option<String>,
865    /// The new body (`--body`); `None` leaves the body alone.
866    pub body: Option<String>,
867}
868
869impl PrEdit {
870    /// An edit that leaves both fields alone (the facade rejects both-`None`
871    /// before reaching the wrapper). Start with this and add what you want to
872    /// change via [`title`](PrEdit::title) / [`body`](PrEdit::body).
873    pub fn new() -> Self {
874        Self {
875            title: None,
876            body: None,
877        }
878    }
879
880    /// Set the new title (`--title`).
881    pub fn title(mut self, title: impl Into<String>) -> Self {
882        self.title = Some(title.into());
883        self
884    }
885
886    /// Set the new body (`--body`).
887    pub fn body(mut self, body: impl Into<String>) -> Self {
888        self.body = Some(body.into());
889        self
890    }
891}
892
893impl Default for PrEdit {
894    fn default() -> Self {
895        Self::new()
896    }
897}
898
899/// Which kind of review [`GitHubApi::pr_review`] submits — match on
900/// [`ReviewAction::kind`] to read it back.
901#[derive(Debug, Clone, Copy, PartialEq, Eq)]
902#[non_exhaustive]
903pub enum ReviewKind {
904    /// Approve (`--approve`).
905    Approve,
906    /// Request changes (`--request-changes`).
907    RequestChanges,
908    /// A comment-only review (`--comment`).
909    Comment,
910}
911
912/// What [`GitHubApi::pr_review`] submits (`gh pr review`).
913///
914/// The fields are **private** so the invariant holds by construction: gh
915/// *requires* a body for request-changes/comment reviews, so those are only
916/// reachable through [`request_changes`](ReviewAction::request_changes) /
917/// [`comment`](ReviewAction::comment), which both take the body — an empty-body
918/// request-changes is unrepresentable. Approve's body is optional
919/// ([`approve`](ReviewAction::approve) starts with none; attach one with
920/// [`with_body`](ReviewAction::with_body)). Read the parts back via
921/// [`kind`](ReviewAction::kind) / [`body`](ReviewAction::body).
922#[derive(Debug, Clone, PartialEq, Eq)]
923#[non_exhaustive]
924pub struct ReviewAction {
925    kind: ReviewKind,
926    body: Option<String>,
927}
928
929impl ReviewAction {
930    /// Approve, with no body (`--approve`). Attach one with
931    /// [`with_body`](ReviewAction::with_body).
932    pub fn approve() -> Self {
933        Self {
934            kind: ReviewKind::Approve,
935            body: None,
936        }
937    }
938
939    /// Request changes; gh requires the body
940    /// (`--request-changes --body <body>`).
941    pub fn request_changes(body: impl Into<String>) -> Self {
942        Self {
943            kind: ReviewKind::RequestChanges,
944            body: Some(body.into()),
945        }
946    }
947
948    /// A comment-only review; gh requires the body (`--comment --body <body>`).
949    pub fn comment(body: impl Into<String>) -> Self {
950        Self {
951            kind: ReviewKind::Comment,
952            body: Some(body.into()),
953        }
954    }
955
956    /// Attach or replace the body — mainly to give an [`approve`](ReviewAction::approve)
957    /// a message.
958    pub fn with_body(mut self, body: impl Into<String>) -> Self {
959        self.body = Some(body.into());
960        self
961    }
962
963    /// Which kind of review this is.
964    pub fn kind(&self) -> ReviewKind {
965        self.kind
966    }
967
968    /// The review body, if any.
969    pub fn body(&self) -> Option<&str> {
970        self.body.as_deref()
971    }
972}
973
974/// Options for [`GitHubApi::release_create`] (`gh release create`).
975///
976/// `#[non_exhaustive]`, so build it through [`ReleaseCreate::new`] (the tag) and
977/// the chained [`title`](ReleaseCreate::title) / [`notes`](ReleaseCreate::notes) /
978/// [`draft`](ReleaseCreate::draft) / [`prerelease`](ReleaseCreate::prerelease)
979/// setters rather than a struct literal. Asset uploads are deliberately **out of
980/// scope** — attach files with [`run`](GitHubApi::run) if you need them.
981#[derive(Debug, Clone)]
982#[non_exhaustive]
983pub struct ReleaseCreate {
984    /// The Git tag the release is attached to (gh's bare `<tag>` positional). If no
985    /// such tag exists, `gh` creates one from the default branch's latest state.
986    pub tag: String,
987    /// The release title (`--title`); `None` lets gh default it (to the tag).
988    pub title: Option<String>,
989    /// The release notes / body (`--notes`); `None` leaves notes unset. Note that
990    /// `gh` **requires** notes when run non-interactively, so a headless create
991    /// should set this (or drive `--notes-file`/`--generate-notes` via
992    /// [`run`](GitHubApi::run)) — otherwise gh errors asking for notes.
993    pub notes: Option<String>,
994    /// Save the release as a draft instead of publishing it (`--draft`).
995    pub draft: bool,
996    /// Mark the release as a prerelease (`--prerelease`).
997    pub prerelease: bool,
998}
999
1000impl ReleaseCreate {
1001    /// A published release on `tag`, with gh's default title/notes and neither
1002    /// draft nor prerelease set. Chain the setters to change any of those.
1003    pub fn new(tag: impl Into<String>) -> Self {
1004        Self {
1005            tag: tag.into(),
1006            title: None,
1007            notes: None,
1008            draft: false,
1009            prerelease: false,
1010        }
1011    }
1012
1013    /// Set the release title (`--title`).
1014    pub fn title(mut self, title: impl Into<String>) -> Self {
1015        self.title = Some(title.into());
1016        self
1017    }
1018
1019    /// Set the release notes / body (`--notes`).
1020    pub fn notes(mut self, notes: impl Into<String>) -> Self {
1021        self.notes = Some(notes.into());
1022        self
1023    }
1024
1025    /// Save as a draft instead of publishing (`--draft`).
1026    pub fn draft(mut self) -> Self {
1027        self.draft = true;
1028        self
1029    }
1030
1031    /// Mark the release as a prerelease (`--prerelease`).
1032    pub fn prerelease(mut self) -> Self {
1033        self.prerelease = true;
1034        self
1035    }
1036}
1037
1038/// Options for [`GitHubApi::workflow_dispatch`] (`gh workflow run`), which fires a
1039/// `workflow_dispatch` event for a workflow that declares an `on: workflow_dispatch`
1040/// trigger.
1041///
1042/// `#[non_exhaustive]`, so build it through [`WorkflowDispatch::new`] (the workflow
1043/// selector) and the chained [`git_ref`](WorkflowDispatch::git_ref) /
1044/// [`field`](WorkflowDispatch::field) setters rather than a struct literal — the
1045/// `≥2 options → builder` rule (a target `ref` **and** any number of inputs) the
1046/// crate applies to its multi-option commands.
1047///
1048/// Inputs are emitted as `-f/--raw-field key=value` (the **raw** string form),
1049/// **not** gh's `-F/--field`: the latter interprets a value beginning with `@` as a
1050/// *file to read* (`gh help api`'s `@` syntax), so a caller-supplied value like
1051/// `@/etc/passwd` would exfiltrate a file into the dispatch. `--raw-field` treats
1052/// every value as a literal string, so an arbitrary input value (including a leading
1053/// `-` or `@`) is passed verbatim and safely.
1054#[derive(Debug, Clone)]
1055#[non_exhaustive]
1056pub struct WorkflowDispatch {
1057    /// The workflow to run — its file name (`ci.yml` / `release.yml`) or its display
1058    /// name (gh's `[<workflow-id> | <workflow-name>]` positional). A bare positional,
1059    /// so it is flag-injection guarded (a leading `-` / empty value is refused before
1060    /// spawning), like [`release_view`](GitHubApi::release_view)'s tag.
1061    pub workflow: String,
1062    /// The branch or tag whose version of the workflow file to run (`--ref`); `None`
1063    /// runs the version on the repository's default branch. Rides in a flag-VALUE
1064    /// slot (gh consumes the next token verbatim, like `--branch`), so no positional
1065    /// guard applies.
1066    pub git_ref: Option<String>,
1067    /// `workflow_dispatch` inputs, as ordered `(key, value)` pairs. Each is emitted as
1068    /// `--raw-field key=value` (see the type-level note on why `--raw-field`, not
1069    /// `--field`). Keys must be non-empty after trimming and cannot contain `=` or
1070    /// NUL; values can be any string (a leading `-`/`@` is safe in this flag-VALUE
1071    /// slot).
1072    pub fields: Vec<(String, String)>,
1073}
1074
1075impl WorkflowDispatch {
1076    /// Dispatch `workflow` on the repository's default branch with no inputs. Chain
1077    /// [`git_ref`](WorkflowDispatch::git_ref) to target a branch/tag and
1078    /// [`field`](WorkflowDispatch::field) to add inputs.
1079    pub fn new(workflow: impl Into<String>) -> Self {
1080        Self {
1081            workflow: workflow.into(),
1082            git_ref: None,
1083            fields: Vec::new(),
1084        }
1085    }
1086
1087    /// Set the branch or tag whose version of the workflow file to run (`--ref`).
1088    /// (Named `git_ref` because `ref` is a Rust keyword.)
1089    pub fn git_ref(mut self, git_ref: impl Into<String>) -> Self {
1090        self.git_ref = Some(git_ref.into());
1091        self
1092    }
1093
1094    /// Add one `workflow_dispatch` input (`--raw-field key=value`). Call it once per
1095    /// input; inputs are emitted in the order added.
1096    pub fn field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1097        self.fields.push((key.into(), value.into()));
1098        self
1099    }
1100}
1101
1102/// Which jobs [`GitHubApi::run_rerun`] reruns (`gh run rerun`) — a direct argument
1103/// rather than a builder, since a single toggle doesn't reach the crate's
1104/// `≥2 options → builder` bar. `#[non_exhaustive]` so a future rerun mode is not a
1105/// breaking change.
1106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1107#[non_exhaustive]
1108pub enum RerunScope {
1109    /// Rerun the **entire** run — every job (`gh run rerun <id>`).
1110    All,
1111    /// Rerun **only the failed jobs**, plus their dependencies
1112    /// (`gh run rerun <id> --failed`).
1113    FailedOnly,
1114}
1115
1116/// What the installed `gh` binary supports, probed via
1117/// [`GitHubApi::capabilities`]. A value type — the client holds no state, so
1118/// probe once and keep the result (callers cache it). Mirrors
1119/// [`vcs_git::GitCapabilities`](../vcs_git/struct.GitCapabilities.html) /
1120/// [`vcs_jj::JjCapabilities`](../vcs_jj/struct.JjCapabilities.html).
1121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1122#[non_exhaustive]
1123pub struct GitHubCapabilities {
1124    /// The binary's parsed version.
1125    pub version: GitHubVersion,
1126}
1127
1128/// The oldest `gh` this crate is written against — **2.0.0**, the first release of
1129/// the modern `gh` line. Every command this crate's argv drives lives in 2.x: the
1130/// `--json` read surface (`pr`/`issue`/`repo`/`release … --json`, incl.
1131/// `pr checks --json`), the `pr edit` / `pr checkout` / `pr ready` lifecycle verbs,
1132/// and `api`. A `gh` from the 1.x line is missing parts of that surface, so gating
1133/// here lets [`ensure_supported`](GitHubCapabilities::ensure_supported) reject a
1134/// too-old binary up front with a clear message instead of letting an operation
1135/// fail deep inside gh with a cryptic `unknown command`/`unknown flag`.
1136const MIN_SUPPORTED: GitHubVersion = GitHubVersion {
1137    major: 2,
1138    minor: 0,
1139    patch: 0,
1140};
1141
1142impl GitHubCapabilities {
1143    /// Whether the binary meets the supported floor (gh ≥ 2.0). Every typed
1144    /// operation on [`GitHubApi`] is guaranteed against this minimum.
1145    pub fn is_supported(&self) -> bool {
1146        self.version >= MIN_SUPPORTED
1147    }
1148
1149    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs gh ≥ 2.0,
1150    /// found 1.14.0" instead of a cryptic `unknown command`/`unknown flag` failure
1151    /// once an operation reaches a command the old binary lacks. The pre-flight
1152    /// check a caller runs before driving operations against an untrusted `gh`.
1153    pub fn ensure_supported(&self) -> Result<()> {
1154        if self.is_supported() {
1155            return Ok(());
1156        }
1157        Err(Error::spawn(
1158            BINARY,
1159            std::io::Error::new(
1160                std::io::ErrorKind::Unsupported,
1161                format!(
1162                    "vcs-github requires gh >= {MIN_SUPPORTED}, found {}",
1163                    self.version
1164                ),
1165            ),
1166        ))
1167    }
1168}
1169
1170/// The GitHub operations this crate exposes — the interface consumers code
1171/// against and mock in tests.
1172#[cfg_attr(feature = "mock", mockall::automock)]
1173#[async_trait::async_trait]
1174pub trait GitHubApi: Send + Sync {
1175    /// Run `gh <args>` **in the process's current directory**, returning trimmed
1176    /// stdout (throws on a non-zero exit). A raw escape hatch — you supply the whole
1177    /// argv, so pass `-R owner/repo` to target a specific repo. This method on the
1178    /// client is the **process-cwd** escape hatch; the `at(dir)` bound view's
1179    /// [`run`](GitHubAt::run) is instead **bound to `dir`** (it forwards to
1180    /// [`GitHub::run_in`], so `gh.at(dir).run(…)` runs in the bound repo's cwd, like
1181    /// [`api`](GitHubApi::api)). Use `gh.at(dir).run(…)` (or [`GitHub::run_in`]) for
1182    /// the bound repo (T-035).
1183    async fn run(&self, args: &[String]) -> Result<String>;
1184    /// Like [`GitHubApi::run`] but never errors on a non-zero exit — returns the
1185    /// captured [`ProcessResult`].
1186    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
1187    /// Installed GitHub CLI version (`gh --version`).
1188    async fn version(&self) -> Result<String>;
1189    /// The installed binary's parsed version, as [`GitHubCapabilities`]
1190    /// (`gh --version`). A value type — probe once and keep it; an unrecognisable
1191    /// version banner is an [`ErrorReason::Parse`]. Gate an operation on a minimum `gh`
1192    /// with [`GitHubCapabilities::ensure_supported`].
1193    async fn capabilities(&self) -> Result<GitHubCapabilities>;
1194    /// Whether the user is authenticated (`gh auth status` exits zero). Reflects
1195    /// the exit code as a bool — any non-zero exit reads as `false`, never an
1196    /// error; only a spawn failure or timeout errors. Unscoped: it inspects
1197    /// *every* configured host, so a broken session for one host can make it
1198    /// report `false` even when the host you care about is fine — reach for
1199    /// [`auth_status_for`](GitHubApi::auth_status_for) to scope it.
1200    async fn auth_status(&self) -> Result<bool>;
1201    /// Whether the user is authenticated **for `host`** (`gh auth status
1202    /// --hostname <host>` exits zero) — the host-scoped twin of
1203    /// [`auth_status`](GitHubApi::auth_status). Scoping to the repository's host
1204    /// (build a [`GitHubHost`] from its remote, e.g.
1205    /// [`GitHubHost::from_remote_url`]) means a broken or absent session for
1206    /// *another* host can't turn this into a false negative for the host you
1207    /// target. Like `auth_status`, it folds only the exit code into the bool (any
1208    /// non-zero exit → `false`); a spawn failure or timeout still errors.
1209    /// **Defaulted** to `ErrorReason::Unsupported` so external implementers of the trait
1210    /// keep compiling when the crate bumps (only the `GitHub` concrete impl and the
1211    /// regenerated `MockGitHubApi` override it).
1212    #[allow(unused_variables)]
1213    async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
1214        Err(Error::from(ErrorReason::Unsupported {
1215            operation: "auth_status_for".into(),
1216        }))
1217    }
1218    /// The repository for `dir` (`gh repo view --json …`).
1219    async fn repo_view(&self, dir: &Path) -> Result<RepoView>;
1220    /// Pull requests for `dir` (`gh pr list --limit 100 --json …`). Returns up to
1221    /// 100 open PRs; use [`run`](GitHubApi::run) for more.
1222    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
1223    /// Pull requests selected by `spec` (`--state` / `--limit`). A zero limit is
1224    /// rejected before spawning. **Defaulted** to `ErrorReason::Unsupported` so
1225    /// external trait implementers keep compiling when the crate bumps.
1226    #[allow(unused_variables)]
1227    async fn pr_list_with(&self, dir: &Path, spec: PrList) -> Result<Vec<PullRequest>> {
1228        Err(Error::from(ErrorReason::Unsupported {
1229            operation: "pr_list_with".into(),
1230        }))
1231    }
1232    /// Pull requests whose source branch is `head`, in any state — open, closed,
1233    /// or merged (`gh pr list --head <head> --state all --limit 100 --json …`).
1234    /// Empty when none match; returns up to 100. A flag-like or empty `head` is
1235    /// rejected before spawning so an untrusted branch cannot alter the command.
1236    ///
1237    /// **Defaulted** to `ErrorReason::Unsupported` so external trait implementers keep
1238    /// compiling when the crate bumps.
1239    #[allow(unused_variables)]
1240    async fn pr_list_for_source_branch(&self, dir: &Path, head: &str) -> Result<Vec<PullRequest>> {
1241        Err(Error::from(ErrorReason::Unsupported {
1242            operation: "pr_list_for_source_branch".into(),
1243        }))
1244    }
1245    /// Pull requests that merge `head` into `base`, in any state — open, closed,
1246    /// or merged (`gh pr list --head <head> --base <base> --state all --limit 100
1247    /// --json …`). Each carries its title, URL, and `state`. Empty when none
1248    /// match; returns up to 100 (use [`run`](GitHubApi::run) for more).
1249    async fn pr_list_for_branch(
1250        &self,
1251        dir: &Path,
1252        head: &str,
1253        base: &str,
1254    ) -> Result<Vec<PullRequest>>;
1255    /// A single pull request by number (`gh pr view <n> --json …`).
1256    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
1257    /// Issues for `dir` (`gh issue list --limit 100 --json …`). Returns up to 100
1258    /// open issues; use [`run`](GitHubApi::run) for more.
1259    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
1260    /// Issues selected by `spec` (`--state` / `--limit`). A zero limit is
1261    /// rejected before spawning. **Defaulted** to `ErrorReason::Unsupported` so
1262    /// external trait implementers keep compiling when the crate bumps.
1263    #[allow(unused_variables)]
1264    async fn issue_list_with(&self, dir: &Path, spec: IssueList) -> Result<Vec<Issue>> {
1265        Err(Error::from(ErrorReason::Unsupported {
1266            operation: "issue_list_with".into(),
1267        }))
1268    }
1269    /// Open a pull request, returning its URL (`gh pr create`) — see
1270    /// [`PrCreate`] for the title/body and the optional `head` (source branch;
1271    /// `None` = current branch) / `base` (target; `None` = repo default).
1272    async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
1273    /// Raw GitHub REST/GraphQL response body (`gh api <endpoint>`), run in `dir` so
1274    /// a relative endpoint's `{owner}/{repo}` placeholder resolves against the bound
1275    /// repository — not whatever repo the process's current directory happens to be in.
1276    async fn api(&self, dir: &Path, endpoint: &str) -> Result<String>;
1277
1278    // --- PR lifecycle ----------------------------------------------------
1279
1280    /// Merge a pull request (`gh pr merge <n> --merge|--squash|--rebase
1281    /// [--auto] [--delete-branch]`) — see [`PrMerge`].
1282    async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
1283    /// Mark a draft pull request as ready for review (`gh pr ready <n>`).
1284    async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()>;
1285    /// Close a pull request without merging (`gh pr close <n>
1286    /// [--delete-branch]`); see [`PrClose`].
1287    async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()>;
1288    /// Add labels to an existing pull request (`gh pr edit <n> --add-label <name>`).
1289    #[allow(unused_variables)]
1290    async fn pr_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1291        Err(Error::from(ErrorReason::Unsupported {
1292            operation: "pr_add_labels".into(),
1293        }))
1294    }
1295    /// Remove labels from an existing pull request (`gh pr edit <n> --remove-label <name>`).
1296    #[allow(unused_variables)]
1297    async fn pr_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1298        Err(Error::from(ErrorReason::Unsupported {
1299            operation: "pr_remove_labels".into(),
1300        }))
1301    }
1302    /// Check out a pull request's branch into the working copy at `dir`
1303    /// (`gh pr checkout <n>`) — the head branch is fetched and switched to, so a
1304    /// subsequent build/test/edit runs against the PR locally. Mutates the working
1305    /// copy. **Defaulted** to `ErrorReason::Unsupported` so external implementers of the
1306    /// trait keep compiling when the crate bumps (only the `GitHub` concrete impl
1307    /// and the regenerated `MockGitHubApi` override it).
1308    #[allow(unused_variables)]
1309    async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
1310        Err(Error::from(ErrorReason::Unsupported {
1311            operation: "pr_checkout".into(),
1312        }))
1313    }
1314    /// The PR's checks (`gh pr checks <n> --json …`). gh signals the overall
1315    /// outcome through its exit code — 0 all passed, 8 still pending, 1 some
1316    /// failed — and emits the same JSON either way, so all three return the
1317    /// parsed list; branch on each entry's [`bucket`](CheckRun::bucket). A PR
1318    /// with no checks at all yields an empty list (gh's "no checks reported"
1319    /// exit). Any other exit (no such PR, auth required, …) errors.
1320    async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
1321    /// Submit a review (`gh pr review <n> --approve|--request-changes|--comment
1322    /// [--body <body>]`) — see [`ReviewAction`] (request-changes/comment carry a
1323    /// required body by construction).
1324    async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
1325    /// Add a conversation comment, returning its URL
1326    /// (`gh pr comment <n> --body <body>`).
1327    async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
1328    /// Edit a pull request's title and/or body
1329    /// (`gh pr edit <n> [--title <title>] [--body <body>]`). At least one of
1330    /// `title` or `body` must be `Some` — the facade rejects both-`None`
1331    /// before reaching the wrapper, so the default implementation is
1332    /// unreachable in normal use. **Defaulted** to `ErrorReason::Unsupported` so
1333    /// external implementers of the trait keep compiling when the crate
1334    /// bumps.
1335    #[allow(unused_variables)]
1336    async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
1337        Err(Error::from(ErrorReason::Unsupported {
1338            operation: "pr_edit".into(),
1339        }))
1340    }
1341    /// The PR's submitted reviews and conversation comments
1342    /// (`gh pr view <n> --json reviews,comments`).
1343    async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
1344    /// The PR's diff, one [`FileDiff`] per changed file (`gh pr diff <n>
1345    /// --color never`), through the same unified-diff parser
1346    /// [`vcs-git`](https://docs.rs/vcs-git)/[`vcs-jj`](https://docs.rs/vcs-jj)
1347    /// use — `gh pr diff` emits the same git-format diff `git diff` does.
1348    async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>>;
1349
1350    // --- Actions workflows and runs ---------------------------------------
1351
1352    /// Active workflow definitions (`gh workflow list --limit 50 --json …`).
1353    /// Disabled workflows are hidden; use [`workflow_list_with`](GitHubApi::workflow_list_with)
1354    /// with [`WorkflowList::all`] to include them. **Defaulted** to
1355    /// `ErrorReason::Unsupported` so external trait implementers keep compiling.
1356    #[allow(unused_variables)]
1357    async fn workflow_list(&self, dir: &Path) -> Result<Vec<Workflow>> {
1358        Err(Error::from(ErrorReason::Unsupported {
1359            operation: "workflow_list".into(),
1360        }))
1361    }
1362    /// Workflow definitions selected by `spec` (`--all` / `--limit`). A zero
1363    /// limit is rejected before spawning. **Defaulted** to
1364    /// `ErrorReason::Unsupported` so external trait implementers keep compiling.
1365    #[allow(unused_variables)]
1366    async fn workflow_list_with(&self, dir: &Path, spec: WorkflowList) -> Result<Vec<Workflow>> {
1367        Err(Error::from(ErrorReason::Unsupported {
1368            operation: "workflow_list_with".into(),
1369        }))
1370    }
1371    /// Resolve one workflow by numeric id, display name (case-insensitive), or
1372    /// workflow filename/path. Current `gh workflow view` has no `--json` mode,
1373    /// so this resolves against the complete disabled-inclusive JSON inventory
1374    /// from `gh workflow list` rather than scraping human-readable output.
1375    /// **Defaulted** to `ErrorReason::Unsupported` so external trait implementers
1376    /// keep compiling.
1377    #[allow(unused_variables)]
1378    async fn workflow_view(&self, dir: &Path, selector: &str) -> Result<Workflow> {
1379        Err(Error::from(ErrorReason::Unsupported {
1380            operation: "workflow_view".into(),
1381        }))
1382    }
1383
1384    /// Recent workflow runs, newest first (`gh run list --limit <n>
1385    /// [--branch <b>] --json …`). `branch` is an owned `Option<String>` to keep
1386    /// the trait `mockall`-friendly.
1387    async fn run_list(
1388        &self,
1389        dir: &Path,
1390        limit: u64,
1391        branch: Option<String>,
1392    ) -> Result<Vec<WorkflowRun>>;
1393    /// A single workflow run by id (`gh run view <id> --json …`); the id is
1394    /// [`WorkflowRun::database_id`].
1395    async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
1396    /// Block until the run finishes, then return its final state
1397    /// (`gh run watch <id>`, then a `run view`). Inspect
1398    /// [`conclusion`](WorkflowRun::conclusion) for the outcome — exit codes
1399    /// can't distinguish a failed run from a cancelled one.
1400    ///
1401    /// **Blocks for the whole run.** A client
1402    /// [`default_timeout`](GitHub::default_timeout) kills the watch when it
1403    /// elapses (`ErrorReason::Timeout`) — drive this from a client with no (or a
1404    /// generous) timeout.
1405    async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
1406    /// Fire a `workflow_dispatch` event for a workflow, driven by a
1407    /// [`WorkflowDispatch`] spec (the workflow selector plus an optional target `ref`
1408    /// and inputs) — the whole span kept on one line so rustdoc doesn't read the
1409    /// angle-bracket placeholders as HTML:
1410    /// `gh workflow run <workflow> [--ref <ref>] [--raw-field key=value …]`.
1411    /// The workflow file must declare an `on: workflow_dispatch` trigger.
1412    ///
1413    /// Returns `Result<()>`, **not** a run URL: the underlying GitHub API replies
1414    /// `204 No Content` with no run identifier (the dispatch is asynchronous — the
1415    /// run may not exist yet), so any URL gh prints is best-effort. To find the run
1416    /// this started, poll [`run_list`](GitHubApi::run_list) for the workflow/branch.
1417    /// Exit codes follow gh's convention (`gh help exit-codes`): **0** dispatched,
1418    /// **1** on failure — e.g. an unknown workflow (`HTTP 404: workflow … not found`),
1419    /// a workflow lacking a `workflow_dispatch` trigger, or an unknown input —
1420    /// surfaced as [`ErrorReason::Exit`]; **4** if `gh` is not authenticated.
1421    ///
1422    /// **Defaulted** to `ErrorReason::Unsupported` so external implementers of the trait
1423    /// keep compiling when the crate bumps (only the `GitHub` concrete impl and the
1424    /// regenerated `MockGitHubApi` override it).
1425    #[allow(unused_variables)]
1426    async fn workflow_dispatch(&self, dir: &Path, spec: WorkflowDispatch) -> Result<()> {
1427        Err(Error::from(ErrorReason::Unsupported {
1428            operation: "workflow_dispatch".into(),
1429        }))
1430    }
1431    /// Rerun a completed workflow run (`gh run rerun <id> [--failed]`); pass a
1432    /// [`RerunScope`] to rerun every job ([`All`](RerunScope::All)) or only the
1433    /// failed jobs and their dependencies ([`FailedOnly`](RerunScope::FailedOnly)).
1434    /// The id is [`WorkflowRun::database_id`]; being a `u64`, the bare positional can
1435    /// never look like a flag — nothing to guard.
1436    ///
1437    /// gh queues the rerun and returns; the new run is a *separate*
1438    /// [`WorkflowRun`] — poll [`run_list`](GitHubApi::run_list) or
1439    /// [`run_watch`](GitHubApi::run_watch) for it. Exit codes follow gh's convention
1440    /// (`gh help exit-codes`): **0** queued, **1** on failure — e.g. no such run
1441    /// (`failed to get run: HTTP 404`), or `--failed` on a run with no failed jobs —
1442    /// as [`ErrorReason::Exit`]; **4** if unauthenticated.
1443    ///
1444    /// **Defaulted** to `ErrorReason::Unsupported` so external implementers keep compiling
1445    /// when the crate bumps.
1446    #[allow(unused_variables)]
1447    async fn run_rerun(&self, dir: &Path, id: u64, scope: RerunScope) -> Result<()> {
1448        Err(Error::from(ErrorReason::Unsupported {
1449            operation: "run_rerun".into(),
1450        }))
1451    }
1452    /// Cancel an in-progress workflow run (`gh run cancel <id>`). The id is
1453    /// [`WorkflowRun::database_id`]; being a `u64`, the bare positional can never look
1454    /// like a flag — nothing to guard.
1455    ///
1456    /// Cancellation is a *request* — gh returns once GitHub accepts it, before jobs
1457    /// actually wind down; read the run's terminal state with
1458    /// [`run_view`](GitHubApi::run_view)/[`run_watch`](GitHubApi::run_watch) (a
1459    /// cancelled run's [`conclusion`](WorkflowRun::conclusion) is `"cancelled"`). Exit
1460    /// codes follow gh's convention (`gh help exit-codes`): **0** accepted, **1** on
1461    /// failure — e.g. no such run (`Could not find any workflow run with ID …`), or a
1462    /// run that is already completed (`Cannot cancel a workflow run that is
1463    /// completed`) — as [`ErrorReason::Exit`]; **4** if unauthenticated.
1464    ///
1465    /// **Defaulted** to `ErrorReason::Unsupported` so external implementers keep compiling
1466    /// when the crate bumps.
1467    #[allow(unused_variables)]
1468    async fn run_cancel(&self, dir: &Path, id: u64) -> Result<()> {
1469        Err(Error::from(ErrorReason::Unsupported {
1470            operation: "run_cancel".into(),
1471        }))
1472    }
1473
1474    // --- Issues / releases ---------------------------------------------------
1475
1476    /// Open an issue, returning its URL
1477    /// (`gh issue create --title <title> --body <body>`).
1478    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
1479    /// Open an issue from an extensible spec, including labels. The default keeps
1480    /// old external trait implementations source-compatible and supports the
1481    /// label-free case through [`issue_create`](GitHubApi::issue_create).
1482    async fn issue_create_with(&self, dir: &Path, spec: IssueCreate) -> Result<String> {
1483        if spec.labels.is_empty() {
1484            self.issue_create(dir, &spec.title, &spec.body).await
1485        } else {
1486            Err(Error::from(ErrorReason::Unsupported {
1487                operation: "issue_create_with(labels)".into(),
1488            }))
1489        }
1490    }
1491    /// Add labels to an existing issue (`gh issue edit <n> --add-label <name>`).
1492    #[allow(unused_variables)]
1493    async fn issue_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1494        Err(Error::from(ErrorReason::Unsupported {
1495            operation: "issue_add_labels".into(),
1496        }))
1497    }
1498    /// Remove labels from an existing issue (`gh issue edit <n> --remove-label <name>`).
1499    #[allow(unused_variables)]
1500    async fn issue_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1501        Err(Error::from(ErrorReason::Unsupported {
1502            operation: "issue_remove_labels".into(),
1503        }))
1504    }
1505    /// A single issue by number, with `body`/`url` filled
1506    /// (`gh issue view <n> --json …`).
1507    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
1508    /// Close an issue (`gh issue close <n>`). `number` is a `u64`, so the bare
1509    /// positional can never look like a flag — nothing to guard. **Defaulted** to
1510    /// `ErrorReason::Unsupported` so external implementers of the trait keep compiling
1511    /// when the crate bumps (only the `GitHub` concrete impl and the regenerated
1512    /// `MockGitHubApi` override it).
1513    #[allow(unused_variables)]
1514    async fn issue_close(&self, dir: &Path, number: u64) -> Result<()> {
1515        Err(Error::from(ErrorReason::Unsupported {
1516            operation: "issue_close".into(),
1517        }))
1518    }
1519    /// Reopen a closed issue (`gh issue reopen <n>`). `number` is a `u64`, so the
1520    /// bare positional can never look like a flag — nothing to guard. **Defaulted**
1521    /// to `ErrorReason::Unsupported` so external implementers of the trait keep compiling
1522    /// when the crate bumps (only the `GitHub` concrete impl and the regenerated
1523    /// `MockGitHubApi` override it).
1524    #[allow(unused_variables)]
1525    async fn issue_reopen(&self, dir: &Path, number: u64) -> Result<()> {
1526        Err(Error::from(ErrorReason::Unsupported {
1527            operation: "issue_reopen".into(),
1528        }))
1529    }
1530    /// Add a comment to an issue, returning its URL
1531    /// (`gh issue comment <n> --body <body>`). The body rides in a flag-VALUE slot,
1532    /// so a leading `-` is safe and no argv guard is needed (same as
1533    /// [`pr_comment`](GitHubApi::pr_comment)). **Defaulted** to `ErrorReason::Unsupported`
1534    /// so external implementers of the trait keep compiling when the crate bumps
1535    /// (only the `GitHub` concrete impl and the regenerated `MockGitHubApi` override
1536    /// it).
1537    #[allow(unused_variables)]
1538    async fn issue_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
1539        Err(Error::from(ErrorReason::Unsupported {
1540            operation: "issue_comment".into(),
1541        }))
1542    }
1543    /// Releases, newest first (`gh release list --limit 100 --json …`); `body`/`url`
1544    /// are not fetched here — use [`release_view`](GitHubApi::release_view).
1545    /// Returns up to 100 releases; use [`run`](GitHubApi::run) for more.
1546    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
1547    /// A single release by tag, with `body`/`url` filled
1548    /// (`gh release view <tag> --json …`). gh reports `is_latest` only from
1549    /// [`release_list`](GitHubApi::release_list); here it defaults to `false`.
1550    async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
1551    /// Create a release, returning its URL
1552    /// (`gh release create <tag> [--title <title>] [--notes <notes>] [--draft] [--prerelease]`)
1553    /// — see [`ReleaseCreate`].
1554    /// gh creates the git tag from the default branch's latest state if it doesn't
1555    /// yet exist. Asset uploads are out of scope (attach files with
1556    /// [`run`](GitHubApi::run)). **Defaulted** to `ErrorReason::Unsupported` so external
1557    /// implementers of the trait keep compiling when the crate bumps (only the
1558    /// `GitHub` concrete impl and the regenerated `MockGitHubApi` override it).
1559    #[allow(unused_variables)]
1560    async fn release_create(&self, dir: &Path, spec: ReleaseCreate) -> Result<String> {
1561        Err(Error::from(ErrorReason::Unsupported {
1562            operation: "release_create".into(),
1563        }))
1564    }
1565    /// Delete a release by tag (`gh release delete <tag> --yes`). `--yes` skips gh's
1566    /// confirmation prompt so a headless caller never hangs. Deletes the release
1567    /// only, not the underlying git tag (use `gh release delete --cleanup-tag` via
1568    /// [`run`](GitHubApi::run) for that). **Defaulted** to `ErrorReason::Unsupported` so
1569    /// external implementers keep compiling when the crate bumps.
1570    #[allow(unused_variables)]
1571    async fn release_delete(&self, dir: &Path, tag: &str) -> Result<()> {
1572        Err(Error::from(ErrorReason::Unsupported {
1573            operation: "release_delete".into(),
1574        }))
1575    }
1576}
1577
1578vcs_cli_support::managed_client! {
1579    /// The real GitHub client. Generic over the [`ProcessRunner`] so tests can inject
1580    /// a fake process executor; [`GitHub::new`] uses the real job-backed runner.
1581    ///
1582    /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient). By default it authenticates through `gh`'s own
1583    /// ambient login; attach a [`CredentialProvider`] with
1584    /// [`with_credentials`](GitHub::with_credentials) to supply a token per operation
1585    /// — it is injected as `GH_TOKEN` on every `gh` invocation (or, after
1586    /// [`with_host`](GitHub::with_host) targets a GitHub Enterprise Server host,
1587    /// as `GH_ENTERPRISE_TOKEN` — the variable `gh` reads for that host).
1588    pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
1589}
1590
1591impl<R: ProcessRunner> GitHub<R> {
1592    /// Supply credentials per operation via a [`CredentialProvider`] — opt-in, off
1593    /// by default (ambient `gh` auth). The resolved token is injected as `GH_TOKEN`
1594    /// on every `gh` invocation, overriding the ambient login for this client.
1595    #[must_use]
1596    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
1597        self.core = self.core.with_credentials(provider);
1598        self
1599    }
1600
1601    /// Convenience for the common case: authenticate with a single static `token`,
1602    /// injected as `GH_TOKEN`. Shorthand for
1603    /// `with_credentials(Arc::new(StaticCredential::token(token)))`.
1604    #[must_use]
1605    pub fn with_token(self, token: impl Into<Secret>) -> Self {
1606        self.with_credentials(Arc::new(StaticCredential::token(token)))
1607    }
1608
1609    /// Convenience: read the token from environment variable `var` at request time
1610    /// (injected as `GH_TOKEN`); if `var` is unset/empty, fall back to ambient auth.
1611    /// Shorthand for `with_credentials(Arc::new(EnvToken::new(var)))`.
1612    #[must_use]
1613    pub fn with_env_token(self, var: impl Into<String>) -> Self {
1614        self.with_credentials(Arc::new(EnvToken::new(var)))
1615    }
1616
1617    /// Bind this client to a GitHub `host`, so a supplied credential is injected
1618    /// into the environment variable `gh` reads for **that** host, and gh's default
1619    /// host is set accordingly:
1620    ///
1621    /// - **github.com** ([`GitHubHost::github_com`]) → the token goes to `GH_TOKEN`
1622    ///   (the SaaS default, unchanged) and `GH_HOST` is `github.com`.
1623    /// - a **GitHub Enterprise Server** host → the token goes to
1624    ///   `GH_ENTERPRISE_TOKEN` (the variable `gh` uses for a non-github.com host)
1625    ///   and `GH_HOST` is set to that host, so gh's non-repo commands resolve
1626    ///   against it. The github.com `GH_TOKEN` is **not** set, so an enterprise
1627    ///   secret never lands in the github.com token env (nor vice versa).
1628    ///
1629    /// Compose with [`with_credentials`](GitHub::with_credentials) /
1630    /// [`with_token`](GitHub::with_token) / [`with_env_token`](GitHub::with_env_token)
1631    /// in either order — the host selects the env var, the provider supplies the
1632    /// secret. The bound host also travels in each operation's [`CredentialRequest`],
1633    /// so a **host-keyed** provider returns the secret for *this* host and never a
1634    /// neighbouring instance's. For several hosts, build **one client per host**:
1635    /// each injects only its own host's token, so a broken or missing credential for
1636    /// one host can't leak into another. Without a host binding the client behaves
1637    /// exactly as before — github.com semantics, credential injected as `GH_TOKEN`,
1638    /// and the request carries no host (a host-keyed provider that can't place it
1639    /// defers to ambient auth).
1640    ///
1641    /// `GH_HOST` only steers gh's host inference for commands with **no repository
1642    /// context**; a repo-scoped command still resolves its host from the working
1643    /// directory's remote, so binding a host does not override a repo you point a
1644    /// method at — use a host-bound client with repositories on that host.
1645    #[must_use]
1646    pub fn with_host(mut self, host: GitHubHost) -> Self {
1647        self.core = self
1648            .core
1649            .with_token_env(CredentialService::GitHub, host.token_env_var())
1650            // Carry the (canonical, lower-cased) host into every operation's
1651            // `CredentialRequest`, so a host-keyed `CredentialProvider` resolves the
1652            // secret for *this* host and nothing else — one instance's token can't
1653            // land in another host's `gh` command.
1654            .with_expected_host(host.as_str())
1655            .default_env("GH_HOST", host.as_str());
1656        self
1657    }
1658}
1659
1660#[async_trait::async_trait]
1661impl<R: ProcessRunner> GitHubApi for GitHub<R> {
1662    async fn run(&self, args: &[String]) -> Result<String> {
1663        self.core.run(args).await
1664    }
1665
1666    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1667        self.core.output_string(args).await
1668    }
1669
1670    async fn version(&self) -> Result<String> {
1671        self.core.run(["--version"]).await
1672    }
1673
1674    async fn capabilities(&self) -> Result<GitHubCapabilities> {
1675        let raw = self.version().await?;
1676        let version = parse::parse_gh_version(&raw).ok_or_else(|| {
1677            Error::parse(
1678                BINARY,
1679                format!("unrecognisable `gh --version` output: {raw:?}"),
1680            )
1681        })?;
1682        Ok(GitHubCapabilities { version })
1683    }
1684
1685    async fn auth_status(&self) -> Result<bool> {
1686        // `gh auth status` exits 0 when authenticated, non-zero when not — an
1687        // exit-code answer. `exit_code` reads the exit code without erroring on a
1688        // non-zero one (a spawn failure or timeout still errors), so ANY non-zero
1689        // exit — not just the documented 1 — maps to "not authenticated" rather
1690        // than surfacing as an error. `probe` would reject an unusual exit code.
1691        Ok(self.core.exit_code(["auth", "status"]).await? == 0)
1692    }
1693
1694    async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
1695        // `--hostname <host>` scopes the probe to one host: `gh auth status` with
1696        // no hostname inspects *every* configured host, so a single broken session
1697        // (a different host, an expired enterprise login) can flip the exit code
1698        // non-zero — a false negative for the host we actually target. Same
1699        // exit-code-as-bool contract as `auth_status` (a spawn failure or timeout
1700        // still errors — see `exit_code`). `host` is a validated `GitHubHost`, so
1701        // the `--hostname` value can never be flag-like or empty.
1702        Ok(self
1703            .core
1704            .exit_code(["auth", "status", "--hostname", host.as_str()])
1705            .await?
1706            == 0)
1707    }
1708
1709    async fn repo_view(&self, dir: &Path) -> Result<RepoView> {
1710        self.core
1711            .try_parse(
1712                self.core
1713                    .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
1714                parse::parse_repo,
1715            )
1716            .await
1717    }
1718
1719    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
1720        self.pr_list_with(dir, PrList::default()).await
1721    }
1722
1723    async fn pr_list_with(&self, dir: &Path, spec: PrList) -> Result<Vec<PullRequest>> {
1724        reject_zero_limit("pr_list_with", spec.limit)?;
1725        let limit = spec.limit.to_string();
1726        self.core
1727            .try_parse(
1728                self.core.command_in(
1729                    dir,
1730                    [
1731                        "pr",
1732                        "list",
1733                        "--state",
1734                        spec.state.as_arg(),
1735                        "--limit",
1736                        limit.as_str(),
1737                        "--json",
1738                        PR_FIELDS,
1739                    ],
1740                ),
1741                |s| vcs_cli_support::json::from_json(BINARY, s),
1742            )
1743            .await
1744    }
1745
1746    async fn pr_list_for_branch(
1747        &self,
1748        dir: &Path,
1749        head: &str,
1750        base: &str,
1751    ) -> Result<Vec<PullRequest>> {
1752        reject_flag_like("head", head)?;
1753        reject_flag_like("base", base)?;
1754        // `--state all` so a closed/merged PR for this branch pair is reported
1755        // too, not just open ones (gh's default); the caller filters on `state`.
1756        self.core
1757            .try_parse(
1758                self.core.command_in(
1759                    dir,
1760                    [
1761                        "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
1762                        "100", "--json", PR_FIELDS,
1763                    ],
1764                ),
1765                |s| vcs_cli_support::json::from_json(BINARY, s),
1766            )
1767            .await
1768    }
1769
1770    async fn pr_list_for_source_branch(&self, dir: &Path, head: &str) -> Result<Vec<PullRequest>> {
1771        reject_flag_like("head", head)?;
1772        self.core
1773            .try_parse(
1774                self.core.command_in(
1775                    dir,
1776                    [
1777                        "pr", "list", "--head", head, "--state", "all", "--limit", "100", "--json",
1778                        PR_FIELDS,
1779                    ],
1780                ),
1781                |s| vcs_cli_support::json::from_json(BINARY, s),
1782            )
1783            .await
1784    }
1785
1786    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
1787        let n = number.to_string();
1788        self.core
1789            .try_parse(
1790                self.core
1791                    .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
1792                |s| vcs_cli_support::json::from_json(BINARY, s),
1793            )
1794            .await
1795    }
1796
1797    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
1798        self.issue_list_with(dir, IssueList::default()).await
1799    }
1800
1801    async fn issue_list_with(&self, dir: &Path, spec: IssueList) -> Result<Vec<Issue>> {
1802        reject_zero_limit("issue_list_with", spec.limit)?;
1803        let limit = spec.limit.to_string();
1804        self.core
1805            .try_parse(
1806                self.core.command_in(
1807                    dir,
1808                    [
1809                        "issue",
1810                        "list",
1811                        "--state",
1812                        spec.state.as_arg(),
1813                        "--limit",
1814                        limit.as_str(),
1815                        "--json",
1816                        ISSUE_LIST_FIELDS,
1817                    ],
1818                ),
1819                |s| vcs_cli_support::json::from_json(BINARY, s),
1820            )
1821            .await
1822    }
1823
1824    async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
1825        let mut args = vec![
1826            "pr",
1827            "create",
1828            "--title",
1829            spec.title.as_str(),
1830            "--body",
1831            spec.body.as_str(),
1832        ];
1833        if let Some(head) = spec.head.as_deref() {
1834            args.push("--head");
1835            args.push(head);
1836        }
1837        if let Some(base) = spec.base.as_deref() {
1838            args.push("--base");
1839            args.push(base);
1840        }
1841        if !spec.labels.is_empty() {
1842            reject_invalid_labels("pr_create", &spec.labels)?;
1843            for label in &spec.labels {
1844                args.push("--label");
1845                args.push(label);
1846            }
1847        }
1848        self.core.run(self.core.command_in(dir, args)).await
1849    }
1850
1851    async fn api(&self, dir: &Path, endpoint: &str) -> Result<String> {
1852        reject_flag_like("endpoint", endpoint)?;
1853        self.core
1854            .run(self.core.command_in(dir, ["api", endpoint]))
1855            .await
1856    }
1857
1858    async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
1859        let n = number.to_string();
1860        let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
1861        if merge.auto {
1862            args.push("--auto");
1863        }
1864        if merge.delete_branch {
1865            args.push("--delete-branch");
1866        }
1867        self.core.run_unit(self.core.command_in(dir, args)).await
1868    }
1869
1870    async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()> {
1871        let n = number.to_string();
1872        self.core
1873            .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
1874            .await
1875    }
1876
1877    async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()> {
1878        let n = number.to_string();
1879        let mut args = vec!["pr", "close", n.as_str()];
1880        if spec.delete_branch {
1881            args.push("--delete-branch");
1882        }
1883        self.core.run_unit(self.core.command_in(dir, args)).await
1884    }
1885
1886    async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
1887        // `number` is a `u64`, so it can never look like a flag — nothing to
1888        // guard with `reject_flag_like`. `gh pr checkout` fetches the PR's head
1889        // branch and switches the working copy to it (no structured output).
1890        let n = number.to_string();
1891        self.core
1892            .run_unit(self.core.command_in(dir, ["pr", "checkout", n.as_str()]))
1893            .await
1894    }
1895
1896    async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
1897        let n = number.to_string();
1898        let res = self
1899            .core
1900            .output_string(
1901                self.core
1902                    .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
1903            )
1904            .await?;
1905        match res.code() {
1906            // gh's exit code carries the *overall* outcome (0 = all pass,
1907            // 8 = pending, 1 = some failed) but prints the same JSON for all
1908            // three — parse it and let the caller branch on each `bucket`.
1909            // A parse failure here is a real schema problem and must surface
1910            // as `ErrorReason::Parse`, not be masked by the exit code.
1911            Some(0) => vcs_cli_support::json::from_json(BINARY, res.stdout()),
1912            Some(1 | 8) if !res.stdout().trim().is_empty() => {
1913                vcs_cli_support::json::from_json(BINARY, res.stdout())
1914            }
1915            // gh exits 1 with NO JSON for a PR that simply has no checks — the
1916            // one bare non-zero we read as an empty list (cf. jj's
1917            // `resolve_list` and its "No conflicts" exit). Matched
1918            // case-insensitively so a capitalization tweak in gh's wording
1919            // ("no checks reported on the 'X' branch") doesn't turn the empty case
1920            // into a hard error.
1921            _ if res
1922                .stderr()
1923                .to_ascii_lowercase()
1924                .contains("no checks reported") =>
1925            {
1926                Ok(Vec::new())
1927            }
1928            // Anything else (no such PR, auth required, timeout, signal…) is a
1929            // genuine failure; `ensure_success` builds the faithful error.
1930            _ => {
1931                let _ = res.ensure_success()?;
1932                Ok(Vec::new()) // unreachable: a non-zero exit always errors above.
1933            }
1934        }
1935    }
1936
1937    async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
1938        let n = number.to_string();
1939        let mut args = vec!["pr", "review", n.as_str()];
1940        args.push(match action.kind() {
1941            ReviewKind::Approve => "--approve",
1942            ReviewKind::RequestChanges => "--request-changes",
1943            ReviewKind::Comment => "--comment",
1944        });
1945        if let Some(body) = action.body() {
1946            args.push("--body");
1947            args.push(body);
1948        }
1949        self.core.run_unit(self.core.command_in(dir, args)).await
1950    }
1951
1952    async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
1953        // `--body` is mandatory here: without it gh falls back to an
1954        // interactive prompt, which would hang a headless run.
1955        let n = number.to_string();
1956        self.core
1957            .run(
1958                self.core
1959                    .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
1960            )
1961            .await
1962    }
1963
1964    async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
1965        // `--title` and `--body` are flag-VALUE positions: gh consumes the
1966        // next token verbatim, so the leading-`-` check is not needed here.
1967        // The facade rejects both-`None` before reaching this; an empty string
1968        // is intentional (clears the field). We still skip absent fields so
1969        // the argv doesn't carry a stray `--title` with no value.
1970        let n = number.to_string();
1971        let mut args = vec!["pr", "edit", n.as_str()];
1972        if let Some(title) = edit.title.as_deref() {
1973            args.push("--title");
1974            args.push(title);
1975        }
1976        if let Some(body) = edit.body.as_deref() {
1977            args.push("--body");
1978            args.push(body);
1979        }
1980        self.core.run_unit(self.core.command_in(dir, args)).await
1981    }
1982
1983    async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
1984        let n = number.to_string();
1985        self.core
1986            .try_parse(
1987                self.core.command_in(
1988                    dir,
1989                    ["pr", "view", n.as_str(), "--json", "reviews,comments"],
1990                ),
1991                parse::parse_feedback,
1992            )
1993            .await
1994    }
1995
1996    async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>> {
1997        self.pr_diff_within(dir, number, self.core.output_budget())
1998            .await
1999    }
2000
2001    async fn workflow_list(&self, dir: &Path) -> Result<Vec<Workflow>> {
2002        self.workflow_list_with(dir, WorkflowList::default()).await
2003    }
2004
2005    async fn workflow_list_with(&self, dir: &Path, spec: WorkflowList) -> Result<Vec<Workflow>> {
2006        reject_zero_limit("workflow_list_with", spec.limit)?;
2007        let limit = spec.limit.to_string();
2008        let mut args = vec!["workflow", "list", "--limit", limit.as_str()];
2009        if spec.include_disabled {
2010            args.push("--all");
2011        }
2012        args.extend(["--json", WORKFLOW_FIELDS]);
2013        self.core
2014            .try_parse(self.core.command_in(dir, args), |s| {
2015                vcs_cli_support::json::from_json(BINARY, s)
2016            })
2017            .await
2018    }
2019
2020    async fn workflow_view(&self, dir: &Path, selector: &str) -> Result<Workflow> {
2021        if selector.is_empty() {
2022            return resolve_workflow(Vec::new(), selector);
2023        }
2024        // `gh workflow view` deliberately has no JSON exporter. `workflow list`
2025        // delegates to gh's paginated Actions workflow API and exposes exactly the
2026        // typed fields we need, so request an effectively-unbounded inventory and
2027        // resolve the same id/name/file selector forms without scraping text.
2028        let workflows = self
2029            .workflow_list_with(
2030                dir,
2031                WorkflowList::new().all().limit(WORKFLOW_VIEW_LOOKUP_LIMIT),
2032            )
2033            .await?;
2034        resolve_workflow(workflows, selector)
2035    }
2036
2037    async fn run_list(
2038        &self,
2039        dir: &Path,
2040        limit: u64,
2041        branch: Option<String>,
2042    ) -> Result<Vec<WorkflowRun>> {
2043        let limit = limit.to_string();
2044        let mut args = vec!["run", "list", "--limit", limit.as_str()];
2045        if let Some(branch) = branch.as_deref() {
2046            args.push("--branch");
2047            args.push(branch);
2048        }
2049        args.extend(["--json", RUN_FIELDS]);
2050        self.core
2051            .try_parse(self.core.command_in(dir, args), |s| {
2052                vcs_cli_support::json::from_json(BINARY, s)
2053            })
2054            .await
2055    }
2056
2057    async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
2058        let id = id.to_string();
2059        self.core
2060            .try_parse(
2061                self.core
2062                    .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
2063                |s| vcs_cli_support::json::from_json(BINARY, s),
2064            )
2065            .await
2066    }
2067
2068    async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
2069        // Block until the run completes. `--exit-status` is deliberately NOT
2070        // passed: it would map the run's outcome onto the exit code (1 failed,
2071        // 2 cancelled), which can't be reported faithfully — the follow-up
2072        // `run view`'s `conclusion` can. Without it, a non-zero watch exit is a
2073        // genuine error (no such run, auth, …). `output_string` does NOT error on a
2074        // timeout (it returns the result with a timeout flag), so
2075        // `ensure_success` is what surfaces a killed watch as `ErrorReason::Timeout`
2076        // instead of reading a half-finished run below.
2077        let id_str = id.to_string();
2078        // `gh run watch` re-prints the full job table every ~3 s until the run ends,
2079        // so over a multi-hour run its stdout grows to tens of MB — all of which we
2080        // discard (only the exit status matters; the result comes from `run_view`). A
2081        // five-minute output-inactivity watchdog detects a wedged `gh` without
2082        // constraining the run's total duration.
2083        // Bound the retained buffer (drop-oldest) so a long watch can't accumulate
2084        // unboundedly; the last 256 lines / 256 KiB are plenty for a failure message.
2085        // (`docs/audit-2026-07.md` R5.)
2086        //
2087        // Expressed through the shared [`OutputBudget`] so this fixed watch cap and
2088        // the configurable content-op budget are the *same* mechanism (T-049): this
2089        // is the drop-oldest *diagnostic* projection (`diagnostic_policy`) — a bounded
2090        // tail that never turns a real watch failure into `OutputTooLarge` — not the
2091        // fail-loud *content* projection the diff/show verbs use.
2092        let watch_budget = OutputBudget::bytes(256 * 1024).with_max_lines(256);
2093        let cmd = self
2094            .core
2095            .command_in(dir, ["run", "watch", id_str.as_str()])
2096            .inactivity_timeout(RUN_WATCH_INACTIVITY_TIMEOUT)
2097            .output_buffer(
2098                watch_budget
2099                    .diagnostic_policy()
2100                    .expect("a byte/line budget yields a diagnostic policy"),
2101            );
2102        let _ = self.core.output_string(cmd).await?.ensure_success()?;
2103        self.run_view(dir, id).await
2104    }
2105
2106    async fn workflow_dispatch(&self, dir: &Path, spec: WorkflowDispatch) -> Result<()> {
2107        // `<workflow>` is a bare positional — guard it against flag-injection/empty
2108        // exactly like `release_view`/`api`. `--ref <ref>` and each input
2109        // `--raw-field key=value` ride in flag-VALUE slots, so gh consumes the next
2110        // token verbatim (a leading `-` is safe there, same as `--branch`/`--body`)
2111        // — no positional guard applies. Inputs use `--raw-field` (NOT `--field`,
2112        // whose `@value` reads a FILE), so a caller value like `@/etc/passwd` stays a
2113        // literal string. gh's dispatch API returns 204 No Content, so there is no
2114        // run id to return — hence `run_unit` (poll `run_list` to find the run).
2115        reject_flag_like("workflow", spec.workflow.as_str())?;
2116        reject_invalid_workflow_dispatch_fields(&spec.fields)?;
2117        // Own the `key=value` tokens before `args` borrows them (declared first so it
2118        // outlives `args`, which holds `&str` into it).
2119        let fields: Vec<String> = spec
2120            .fields
2121            .iter()
2122            .map(|(k, v)| format!("{k}={v}"))
2123            .collect();
2124        let mut args = vec!["workflow", "run", spec.workflow.as_str()];
2125        if let Some(git_ref) = spec.git_ref.as_deref() {
2126            args.push("--ref");
2127            args.push(git_ref);
2128        }
2129        for field in &fields {
2130            args.push("--raw-field");
2131            args.push(field.as_str());
2132        }
2133        self.core.run_unit(self.core.command_in(dir, args)).await
2134    }
2135
2136    async fn run_rerun(&self, dir: &Path, id: u64, scope: RerunScope) -> Result<()> {
2137        // `<run-id>` is a `u64`, so the bare positional can never look like a flag —
2138        // nothing to guard (same as `issue_close`). `--failed` is presence-only.
2139        let id = id.to_string();
2140        let mut args = vec!["run", "rerun", id.as_str()];
2141        if scope == RerunScope::FailedOnly {
2142            args.push("--failed");
2143        }
2144        self.core.run_unit(self.core.command_in(dir, args)).await
2145    }
2146
2147    async fn run_cancel(&self, dir: &Path, id: u64) -> Result<()> {
2148        // `<run-id>` is a `u64`, so the bare positional can never look like a flag —
2149        // nothing to guard.
2150        let id = id.to_string();
2151        self.core
2152            .run_unit(self.core.command_in(dir, ["run", "cancel", id.as_str()]))
2153            .await
2154    }
2155
2156    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
2157        self.issue_create_with(dir, IssueCreate::new(title, body))
2158            .await
2159    }
2160
2161    async fn issue_create_with(&self, dir: &Path, spec: IssueCreate) -> Result<String> {
2162        if !spec.labels.is_empty() {
2163            reject_invalid_labels("issue_create_with", &spec.labels)?;
2164        }
2165        let mut args = vec![
2166            "issue",
2167            "create",
2168            "--title",
2169            spec.title.as_str(),
2170            "--body",
2171            spec.body.as_str(),
2172        ];
2173        for label in &spec.labels {
2174            args.push("--label");
2175            args.push(label);
2176        }
2177        self.core.run(self.core.command_in(dir, args)).await
2178    }
2179
2180    async fn pr_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2181        reject_invalid_labels("pr_add_labels", labels)?;
2182        let number = number.to_string();
2183        let mut args = vec!["pr", "edit", number.as_str()];
2184        for label in labels {
2185            args.push("--add-label");
2186            args.push(label);
2187        }
2188        self.core.run_unit(self.core.command_in(dir, args)).await
2189    }
2190
2191    async fn pr_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2192        reject_invalid_labels("pr_remove_labels", labels)?;
2193        let number = number.to_string();
2194        let mut args = vec!["pr", "edit", number.as_str()];
2195        for label in labels {
2196            args.push("--remove-label");
2197            args.push(label);
2198        }
2199        self.core.run_unit(self.core.command_in(dir, args)).await
2200    }
2201
2202    async fn issue_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2203        reject_invalid_labels("issue_add_labels", labels)?;
2204        let number = number.to_string();
2205        let mut args = vec!["issue", "edit", number.as_str()];
2206        for label in labels {
2207            args.push("--add-label");
2208            args.push(label);
2209        }
2210        self.core.run_unit(self.core.command_in(dir, args)).await
2211    }
2212
2213    async fn issue_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2214        reject_invalid_labels("issue_remove_labels", labels)?;
2215        let number = number.to_string();
2216        let mut args = vec!["issue", "edit", number.as_str()];
2217        for label in labels {
2218            args.push("--remove-label");
2219            args.push(label);
2220        }
2221        self.core.run_unit(self.core.command_in(dir, args)).await
2222    }
2223
2224    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
2225        let n = number.to_string();
2226        self.core
2227            .try_parse(
2228                self.core.command_in(
2229                    dir,
2230                    ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
2231                ),
2232                |s| vcs_cli_support::json::from_json(BINARY, s),
2233            )
2234            .await
2235    }
2236
2237    async fn issue_close(&self, dir: &Path, number: u64) -> Result<()> {
2238        let n = number.to_string();
2239        self.core
2240            .run_unit(self.core.command_in(dir, ["issue", "close", n.as_str()]))
2241            .await
2242    }
2243
2244    async fn issue_reopen(&self, dir: &Path, number: u64) -> Result<()> {
2245        let n = number.to_string();
2246        self.core
2247            .run_unit(self.core.command_in(dir, ["issue", "reopen", n.as_str()]))
2248            .await
2249    }
2250
2251    async fn issue_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
2252        // `--body` is mandatory here: without it gh falls back to an interactive
2253        // prompt, which would hang a headless run (same as `pr_comment`). The body
2254        // rides in a flag-VALUE slot, so a leading `-` is safe — no argv guard.
2255        let n = number.to_string();
2256        self.core
2257            .run(
2258                self.core
2259                    .command_in(dir, ["issue", "comment", n.as_str(), "--body", body]),
2260            )
2261            .await
2262    }
2263
2264    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
2265        self.core
2266            .try_parse(
2267                self.core.command_in(
2268                    dir,
2269                    [
2270                        "release",
2271                        "list",
2272                        "--limit",
2273                        "100",
2274                        "--json",
2275                        RELEASE_LIST_FIELDS,
2276                    ],
2277                ),
2278                |s| vcs_cli_support::json::from_json(BINARY, s),
2279            )
2280            .await
2281    }
2282
2283    async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
2284        reject_flag_like("tag", tag)?;
2285        self.core
2286            .try_parse(
2287                self.core
2288                    .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
2289                |s| vcs_cli_support::json::from_json(BINARY, s),
2290            )
2291            .await
2292    }
2293
2294    async fn release_create(&self, dir: &Path, spec: ReleaseCreate) -> Result<String> {
2295        // `<tag>` is a bare positional — guard it against flag-injection/empty the
2296        // same way `release_view` does. `--title`/`--notes` are flag-VALUE slots (gh
2297        // consumes the next token verbatim), so they need no guard; `--draft`/
2298        // `--prerelease` are presence-only. gh prints the new release's URL.
2299        reject_flag_like("tag", spec.tag.as_str())?;
2300        let mut args = vec!["release", "create", spec.tag.as_str()];
2301        if let Some(title) = spec.title.as_deref() {
2302            args.push("--title");
2303            args.push(title);
2304        }
2305        if let Some(notes) = spec.notes.as_deref() {
2306            args.push("--notes");
2307            args.push(notes);
2308        }
2309        if spec.draft {
2310            args.push("--draft");
2311        }
2312        if spec.prerelease {
2313            args.push("--prerelease");
2314        }
2315        self.core.run(self.core.command_in(dir, args)).await
2316    }
2317
2318    async fn release_delete(&self, dir: &Path, tag: &str) -> Result<()> {
2319        // `<tag>` is a bare positional — guarded like `release_view`. `--yes` skips
2320        // gh's interactive confirmation so a headless delete never hangs on a prompt.
2321        reject_flag_like("tag", tag)?;
2322        self.core
2323            .run_unit(
2324                self.core
2325                    .command_in(dir, ["release", "delete", tag, "--yes"]),
2326            )
2327            .await
2328    }
2329}
2330
2331impl<R: ProcessRunner> GitHub<R> {
2332    /// [`pr_diff`](GitHubApi::pr_diff) with an explicit per-call [`OutputBudget`],
2333    /// instead of this client's [`default_output_budget`](GitHub::default_output_budget).
2334    /// Past the ceiling the read errors with
2335    /// [`ErrorReason::OutputTooLarge`] (actual and
2336    /// allowed sizes) rather than buffering an unbounded diff — the override for a
2337    /// legitimately huge PR.
2338    pub async fn pr_diff_within(
2339        &self,
2340        dir: &Path,
2341        number: u64,
2342        budget: OutputBudget,
2343    ) -> Result<Vec<FileDiff>> {
2344        // `run_untrimmed_within`: a diff's trailing content is meaningful (a hunk's
2345        // last line, a missing trailing newline) — trimming it before parsing could
2346        // desync the parser from `git`'s own byte-exact output. `--color never` keeps
2347        // the output free of ANSI even if stdout were ever a tty. The budget bounds it.
2348        let n = number.to_string();
2349        let text = self
2350            .core
2351            .run_untrimmed_within(
2352                self.core
2353                    .command_in(dir, ["pr", "diff", n.as_str(), "--color", "never"]),
2354                budget,
2355            )
2356            .await?;
2357        Ok(vcs_diff::parse_diff(&text))
2358    }
2359
2360    /// Bind this client to `dir`, returning a [`GitHubAt`] handle whose `dir`-taking
2361    /// methods omit that argument: `gh.at(dir).pr_list()` runs
2362    /// [`pr_list`](GitHubApi::pr_list) against `dir`.
2363    pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
2364        GitHubAt { gh: self, dir }
2365    }
2366}
2367
2368// The six raw escape-hatch helpers (`run_args`/`run_raw_args`/`run_in`/… and the
2369// `*_in` twins) are byte-identical forwards into `core` across all five CLI
2370// wrappers, so the shared macro in `vcs-cli-support` generates them (see
2371// `vcs_cli_support::raw_run_forwarders!`).
2372vcs_cli_support::raw_run_forwarders! {
2373    GitHub, "gh", "\"pr\", \"list\"", ", so `gh` infers the repo from `dir`'s remote",
2374    "only the working directory is bound, no `-R`/extra flag is injected"
2375}
2376
2377/// A [`GitHub`] client with a working directory bound, so its repo-scoped methods
2378/// drop the leading `dir` argument (`gh.at(dir).pr_list()`). Construct one with
2379/// [`GitHub::at`].
2380pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
2381    gh: &'a GitHub<R>,
2382    dir: &'a Path,
2383}
2384
2385// Hand-written rather than derived: holding only references, the view is `Copy`
2386// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
2387// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the handle.
2388impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
2389    fn clone(&self) -> Self {
2390        *self
2391    }
2392}
2393impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
2394
2395// Generate [`GitHubAt`] forwarders: `bare` methods forward verbatim, `dir`
2396// methods inject `self.dir` as the first argument. The shared macro lives in
2397// `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
2398vcs_cli_support::at_forwarders! {
2399    GitHubAt, gh, "GitHub",
2400    bare {
2401        fn version() -> Result<String>;
2402        fn capabilities() -> Result<GitHubCapabilities>;
2403        fn auth_status() -> Result<bool>;
2404        fn auth_status_for(host: &GitHubHost) -> Result<bool>;
2405    }
2406    dir {
2407        fn api(endpoint: &str) -> Result<String>;
2408        fn repo_view() -> Result<RepoView>;
2409        fn pr_list() -> Result<Vec<PullRequest>>;
2410        fn pr_list_with(spec: PrList) -> Result<Vec<PullRequest>>;
2411        fn pr_list_for_source_branch(head: &str) -> Result<Vec<PullRequest>>;
2412        fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
2413        fn pr_view(number: u64) -> Result<PullRequest>;
2414        fn issue_list() -> Result<Vec<Issue>>;
2415        fn issue_list_with(spec: IssueList) -> Result<Vec<Issue>>;
2416        fn pr_create(spec: PrCreate) -> Result<String>;
2417        fn pr_add_labels(number: u64, labels: &[String]) -> Result<()>;
2418        fn pr_remove_labels(number: u64, labels: &[String]) -> Result<()>;
2419        fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
2420        fn pr_mark_ready(number: u64) -> Result<()>;
2421        fn pr_close(number: u64, spec: PrClose) -> Result<()>;
2422        fn pr_checkout(number: u64) -> Result<()>;
2423        fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
2424        fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
2425        fn pr_comment(number: u64, body: &str) -> Result<String>;
2426        fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
2427        fn pr_feedback(number: u64) -> Result<PrFeedback>;
2428        fn pr_diff(number: u64) -> Result<Vec<FileDiff>>;
2429        fn workflow_list() -> Result<Vec<Workflow>>;
2430        fn workflow_list_with(spec: WorkflowList) -> Result<Vec<Workflow>>;
2431        fn workflow_view(selector: &str) -> Result<Workflow>;
2432        fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
2433        fn run_view(id: u64) -> Result<WorkflowRun>;
2434        fn run_watch(id: u64) -> Result<WorkflowRun>;
2435        fn workflow_dispatch(spec: WorkflowDispatch) -> Result<()>;
2436        fn run_rerun(id: u64, scope: RerunScope) -> Result<()>;
2437        fn run_cancel(id: u64) -> Result<()>;
2438        fn issue_create(title: &str, body: &str) -> Result<String>;
2439        fn issue_create_with(spec: IssueCreate) -> Result<String>;
2440        fn issue_add_labels(number: u64, labels: &[String]) -> Result<()>;
2441        fn issue_remove_labels(number: u64, labels: &[String]) -> Result<()>;
2442        fn issue_view(number: u64) -> Result<Issue>;
2443        fn issue_close(number: u64) -> Result<()>;
2444        fn issue_reopen(number: u64) -> Result<()>;
2445        fn issue_comment(number: u64, body: &str) -> Result<String>;
2446        fn release_list() -> Result<Vec<Release>>;
2447        fn release_view(tag: &str) -> Result<Release>;
2448        fn release_create(spec: ReleaseCreate) -> Result<String>;
2449        fn release_delete(tag: &str) -> Result<()>;
2450    }
2451    // Raw escape hatches: bound to `self.dir` (forward to the client's `*_in`
2452    // twins) so `gh.at(dir).run(…)` targets the bound repo's cwd, not the process
2453    // cwd. For the process-cwd hatch call `run`/`run_raw`/… on `GitHub` directly.
2454    raw {
2455        fn run(args: &[String]) -> Result<String> => run_in;
2456        fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
2457        fn run_args(args: &[&str]) -> Result<String> => run_args_in;
2458        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
2459    }
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464    use super::*;
2465    use processkit::testing::{RecordReplayRunner, RecordingRunner, Reply, ScriptedRunner};
2466
2467    /// The [`ErrorReason`] behind a failed result. Since processkit 3.0 `Error` is
2468    /// an opaque wrapper, so the variant assertions below reach the reason through
2469    /// it instead of matching the error directly.
2470    fn err_reason<T>(out: &Result<T>) -> Option<&ErrorReason> {
2471        out.as_ref().err().map(Error::reason)
2472    }
2473
2474    #[test]
2475    fn binary_name_is_gh() {
2476        assert_eq!(BINARY, "gh");
2477    }
2478
2479    /// Path to a cassette recorded by `crates/github/tests/cli.rs`'s
2480    /// `record_*` tests. See CONTRIBUTING.md, "Updating a `gh` CLI cassette",
2481    /// for the re-recording procedure — a cassette here is a fixture that
2482    /// captures "what `gh` actually printed", not our guess at its shape.
2483    fn cassette_path(name: &str) -> std::path::PathBuf {
2484        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2485            .join("tests/cassettes")
2486            .join(name)
2487    }
2488
2489    // `capabilities()` parses the real `gh --version` banner and gates on the 2.0
2490    // floor — covering the minimum, a modern release, and an unrecognisable banner
2491    // (the three cases the scheduled-drift lane also exercises against a real gh).
2492    #[tokio::test]
2493    async fn capability_version_gate_parses_and_gates() {
2494        // Modern gh (the `(date)` trailer and release-URL line are ignored).
2495        let gh = GitHub::with_runner(ScriptedRunner::new().on(
2496            ["gh", "--version"],
2497            Reply::ok(
2498                "gh version 2.40.1 (2024-01-05)\nhttps://github.com/cli/cli/releases/tag/v2.40.1\n",
2499            ),
2500        ));
2501        let caps = gh.capabilities().await.expect("capabilities");
2502        assert_eq!(caps.version.to_string(), "2.40.1");
2503        assert!(caps.is_supported());
2504        caps.ensure_supported().expect("supported");
2505
2506        // Exactly at the floor (2.0.0) is supported.
2507        let at_floor = GitHub::with_runner(
2508            ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version 2.0.0\n")),
2509        );
2510        assert!(
2511            at_floor.capabilities().await.unwrap().is_supported(),
2512            "2.0.0 is exactly the floor"
2513        );
2514
2515        // An old 1.x gh is rejected with a clear message naming the floor + found.
2516        let old = GitHub::with_runner(ScriptedRunner::new().on(
2517            ["gh", "--version"],
2518            Reply::ok("gh version 1.14.0 (2021-11-02)\n"),
2519        ));
2520        let caps = old.capabilities().await.expect("capabilities");
2521        assert_eq!(
2522            caps.version,
2523            GitHubVersion {
2524                major: 1,
2525                minor: 14,
2526                patch: 0
2527            }
2528        );
2529        assert!(!caps.is_supported(), "1.14 is below the 2.0 floor");
2530        let err = caps.ensure_supported().expect_err("unsupported");
2531        let ErrorReason::Spawn { source, .. } = err.reason() else {
2532            panic!("expected Spawn, got {err:?}");
2533        };
2534        let message = source.to_string();
2535        assert!(message.contains(">= 2.0.0"), "names the floor: {message}");
2536        assert!(
2537            message.contains("1.14.0"),
2538            "names the found version: {message}"
2539        );
2540
2541        // A banner with no version token is a parse error, not a silent zero.
2542        let garbage = GitHub::with_runner(
2543            ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version unknowable\n")),
2544        );
2545        let err = garbage.capabilities().await.expect_err("unrecognisable");
2546        assert!(
2547            matches!(err.reason(), ErrorReason::Parse { .. }),
2548            "got {err:?}"
2549        );
2550    }
2551
2552    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
2553    #[allow(dead_code)]
2554    fn bound_view_is_copy_for_default_runner() {
2555        fn assert_copy<T: Copy>() {}
2556        assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
2557    }
2558
2559    // The bound view (`gh.at(dir)`) must produce byte-identical argv to the
2560    // dir-taking call.
2561    #[tokio::test]
2562    async fn bound_view_matches_dir_taking_calls() {
2563        let dir = Path::new("/repo");
2564        let rec = RecordingRunner::replying(Reply::ok("[]"));
2565        let gh = GitHub::with_runner(&rec);
2566
2567        gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
2568        gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
2569        // One of the new lifecycle methods.
2570        gh.run_list(dir, 3, None).await.unwrap();
2571        gh.at(dir).run_list(3, None).await.unwrap();
2572        // A new run-control verb (spec-carrying) forwards identically too.
2573        let disp = || WorkflowDispatch::new("ci.yml").git_ref("main");
2574        gh.workflow_dispatch(dir, disp()).await.unwrap();
2575        gh.at(dir).workflow_dispatch(disp()).await.unwrap();
2576
2577        let calls = rec.calls();
2578        assert_eq!(calls[0].args_str(), calls[1].args_str());
2579        assert_eq!(calls[2].args_str(), calls[3].args_str());
2580        assert_eq!(calls[4].args_str(), calls[5].args_str());
2581        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2582    }
2583
2584    // T-035: the raw escape hatches reached *through* the bound view
2585    // (`gh.at(dir).run…`) now run in the bound `dir`, while the same-named methods
2586    // on the client stay in the process cwd.
2587    #[tokio::test]
2588    async fn bound_view_raw_hatch_runs_in_bound_dir() {
2589        let dir = Path::new("/repo");
2590        let rec = RecordingRunner::replying(Reply::ok(""));
2591        let gh = GitHub::with_runner(&rec);
2592
2593        // Through the bound view: every raw form carries the bound dir as its cwd.
2594        gh.at(dir)
2595            .run(&["pr".to_string(), "list".to_string()])
2596            .await
2597            .unwrap();
2598        let _ = gh
2599            .at(dir)
2600            .run_raw(&["pr".to_string(), "list".to_string()])
2601            .await
2602            .unwrap();
2603        gh.at(dir).run_args(&["pr", "list"]).await.unwrap();
2604        let _ = gh.at(dir).run_raw_args(&["pr", "list"]).await.unwrap();
2605        // On the client directly: the process-cwd escape hatch (no bound dir).
2606        gh.run(&["pr".to_string(), "list".to_string()])
2607            .await
2608            .unwrap();
2609        let _ = gh
2610            .run_raw(&["pr".to_string(), "list".to_string()])
2611            .await
2612            .unwrap();
2613        gh.run_args(&["pr", "list"]).await.unwrap();
2614        let _ = gh.run_raw_args(&["pr", "list"]).await.unwrap();
2615
2616        let calls = rec.calls();
2617        for c in &calls[0..4] {
2618            assert_eq!(
2619                c.cwd.as_deref(),
2620                Some(dir),
2621                "raw call through the bound view runs in the bound dir"
2622            );
2623            assert_eq!(c.args_str(), ["pr", "list"]);
2624        }
2625        for c in &calls[4..8] {
2626            assert_eq!(
2627                c.cwd.as_deref(),
2628                None,
2629                "raw call on the client stays in the process cwd"
2630            );
2631            assert_eq!(c.args_str(), ["pr", "list"]);
2632        }
2633    }
2634
2635    #[tokio::test]
2636    async fn run_args_forwards_str_slices() {
2637        let gh =
2638            GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
2639        assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
2640    }
2641
2642    // Hermetic: real pr_list() arg-building + JSON deserialization against canned
2643    // output — no `gh` binary or network needed, so this runs on CI.
2644    #[tokio::test]
2645    async fn pr_list_parses_scripted_json() {
2646        let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
2647        let gh =
2648            GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "list"], Reply::ok(json)));
2649        let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
2650        assert_eq!(prs.len(), 1);
2651        assert_eq!(prs[0].number, 7);
2652        assert_eq!(prs[0].base_ref_name, "main");
2653    }
2654
2655    // Hermetic: auth_status reflects the exit code without erroring. ANY non-zero
2656    // exit — not just the documented 1 — must read as `false`, never an error
2657    // (an unusual exit code must not be mistaken for a hard failure).
2658    #[tokio::test]
2659    async fn auth_status_reads_exit_code() {
2660        let yes = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::ok("")));
2661        assert!(yes.auth_status().await.unwrap());
2662        let no = GitHub::with_runner(
2663            ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
2664        );
2665        assert!(!no.auth_status().await.unwrap());
2666        // An unexpected exit code (e.g. 2) is still just "not authenticated".
2667        let weird =
2668            GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
2669        assert!(!weird.auth_status().await.unwrap());
2670    }
2671
2672    // Regression guard for the timeout fix: a timed-out auth check must error,
2673    // not silently report "not authenticated" (the old hand-rolled mapping bug).
2674    // Relies on processkit surfacing a timed-out run as `ErrorReason::Timeout`.
2675    #[tokio::test]
2676    async fn auth_status_errors_on_timeout() {
2677        let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::timeout()));
2678        assert!(matches!(
2679            gh.auth_status().await.unwrap_err().reason(),
2680            ErrorReason::Timeout { .. }
2681        ));
2682    }
2683
2684    // pr_create appends `--base <branch>` when given one, and returns the trimmed
2685    // PR URL. The exact command (incl. --base) is the only scripted rule.
2686    #[tokio::test]
2687    async fn pr_create_appends_base_and_returns_url() {
2688        let gh = GitHub::with_runner(ScriptedRunner::new().on(
2689            [
2690                "gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
2691            ],
2692            Reply::ok("https://gh/pr/1\n"),
2693        ));
2694        let url = gh
2695            .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
2696            .await
2697            .expect("should build `pr create … --base main`");
2698        assert_eq!(url, "https://gh/pr/1");
2699    }
2700
2701    // With an explicit head, `pr_create` inserts `--head <branch>` before
2702    // `--base` — so a PR can target an arbitrary source→target pair.
2703    #[tokio::test]
2704    async fn pr_create_appends_head_and_base() {
2705        use processkit::testing::RecordingRunner;
2706        let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
2707        let gh = GitHub::with_runner(&rec);
2708        gh.pr_create(
2709            Path::new("/repo"),
2710            PrCreate::new("T", "B").head("feat/x").base("main"),
2711        )
2712        .await
2713        .expect("pr_create");
2714        assert_eq!(
2715            rec.only_call().args_str(),
2716            [
2717                "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
2718            ]
2719        );
2720    }
2721
2722    // pr_list_for_branch filters by head + base and parses the PR list (title +
2723    // url available on each result).
2724    #[tokio::test]
2725    async fn pr_list_for_branch_filters_and_parses() {
2726        use processkit::testing::RecordingRunner;
2727        let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
2728        let rec = RecordingRunner::replying(Reply::ok(json));
2729        let gh = GitHub::with_runner(&rec);
2730        let prs = gh
2731            .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
2732            .await
2733            .expect("pr_list_for_branch");
2734        assert_eq!(prs.len(), 1);
2735        assert_eq!(prs[0].title, "Merge feat");
2736        assert_eq!(prs[0].url, "https://gh/pr/9");
2737        assert_eq!(
2738            rec.only_call().args_str(),
2739            [
2740                "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
2741                "100", "--json", PR_FIELDS
2742            ]
2743        );
2744    }
2745
2746    // A source-branch lookup deliberately omits `--base`, so a branch with PRs
2747    // against different targets still finds every state of each PR.
2748    #[tokio::test]
2749    async fn pr_list_for_source_branch_filters_all_states_and_guards_head() {
2750        use processkit::testing::RecordingRunner;
2751        let json = r#"[{"number":9,"title":"Merge feat","state":"CLOSED","headRefName":"feat/x","baseRefName":"release","url":"https://gh/pr/9"}]"#;
2752        let rec = RecordingRunner::replying(Reply::ok(json));
2753        let gh = GitHub::with_runner(&rec);
2754        let prs = gh
2755            .pr_list_for_source_branch(Path::new("/repo"), "feat/x")
2756            .await
2757            .expect("pr_list_for_source_branch");
2758        assert_eq!(prs[0].state, "CLOSED");
2759        assert_eq!(
2760            rec.only_call().args_str(),
2761            [
2762                "pr", "list", "--head", "feat/x", "--state", "all", "--limit", "100", "--json",
2763                PR_FIELDS
2764            ]
2765        );
2766
2767        let guarded = GitHub::with_runner(ScriptedRunner::new());
2768        assert!(
2769            guarded
2770                .pr_list_for_source_branch(Path::new("/repo"), "--state=open")
2771                .await
2772                .is_err()
2773        );
2774        // Existing head/base filtering has the same pre-spawn guard.
2775        assert!(
2776            guarded
2777                .pr_list_for_branch(Path::new("/repo"), "feat", "--state=open")
2778                .await
2779                .is_err()
2780        );
2781    }
2782
2783    // The list methods pin an explicit `--limit 100` so the CLI's default page
2784    // size (30) does not silently truncate the result.
2785    #[tokio::test]
2786    async fn list_methods_pin_limit_100() {
2787        let rec = RecordingRunner::replying(Reply::ok("[]"));
2788        let gh = GitHub::with_runner(&rec);
2789        gh.pr_list(Path::new("/r")).await.expect("pr_list");
2790        gh.issue_list(Path::new("/r")).await.expect("issue_list");
2791        gh.release_list(Path::new("/r"))
2792            .await
2793            .expect("release_list");
2794        let calls = rec.calls();
2795        assert_eq!(
2796            calls[0].args_str(),
2797            [
2798                "pr", "list", "--state", "open", "--limit", "100", "--json", PR_FIELDS
2799            ]
2800        );
2801        assert_eq!(
2802            calls[1].args_str(),
2803            [
2804                "issue",
2805                "list",
2806                "--state",
2807                "open",
2808                "--limit",
2809                "100",
2810                "--json",
2811                ISSUE_LIST_FIELDS
2812            ]
2813        );
2814        assert_eq!(
2815            calls[2].args_str(),
2816            [
2817                "release",
2818                "list",
2819                "--limit",
2820                "100",
2821                "--json",
2822                RELEASE_LIST_FIELDS
2823            ]
2824        );
2825    }
2826
2827    #[tokio::test]
2828    async fn list_specs_map_state_and_limit_and_reject_zero() {
2829        let rec = RecordingRunner::replying(Reply::ok("[]"));
2830        let gh = GitHub::with_runner(&rec);
2831        gh.pr_list_with(
2832            Path::new("/r"),
2833            PrList::new().state(PrListState::Merged).limit(7),
2834        )
2835        .await
2836        .expect("merged PR list");
2837        gh.issue_list_with(
2838            Path::new("/r"),
2839            IssueList::new().state(IssueListState::All).limit(9),
2840        )
2841        .await
2842        .expect("all issue list");
2843        let calls = rec.calls();
2844        assert_eq!(
2845            calls[0].args_str(),
2846            [
2847                "pr", "list", "--state", "merged", "--limit", "7", "--json", PR_FIELDS
2848            ]
2849        );
2850        assert_eq!(
2851            calls[1].args_str(),
2852            [
2853                "issue",
2854                "list",
2855                "--state",
2856                "all",
2857                "--limit",
2858                "9",
2859                "--json",
2860                ISSUE_LIST_FIELDS
2861            ]
2862        );
2863
2864        let guarded = RecordingRunner::replying(Reply::ok("[]"));
2865        let gh = GitHub::with_runner(&guarded);
2866        assert!(
2867            gh.pr_list_with(Path::new("/r"), PrList::new().limit(0))
2868                .await
2869                .is_err()
2870        );
2871        assert!(guarded.calls().is_empty(), "zero limit must not spawn");
2872    }
2873
2874    // Without a base, `pr_create` must omit `--base` entirely. RecordingRunner
2875    // captures the exact invocation (and `&rec` plumbs through CliClient), so we
2876    // can assert flag *absence* and the cwd — which prefix matching can't.
2877    #[tokio::test]
2878    async fn pr_create_omits_base_when_none() {
2879        use processkit::testing::RecordingRunner;
2880        let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
2881        let gh = GitHub::with_runner(&rec);
2882        let url = gh
2883            .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
2884            .await
2885            .expect("pr_create");
2886        assert_eq!(url, "https://gh/pr/2");
2887
2888        let call = rec.only_call();
2889        assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
2890        assert_eq!(
2891            call.args_str(),
2892            ["pr", "create", "--title", "T", "--body", "B"]
2893        );
2894        assert!(!call.has_flag("--base"), "no base was given");
2895        assert!(!call.has_flag("--head"), "no head was given");
2896    }
2897
2898    // The injection guard on gh's exposed positionals.
2899    #[tokio::test]
2900    async fn flag_like_positionals_are_rejected_before_spawning() {
2901        let rec = RecordingRunner::replying(Reply::ok(""));
2902        let gh = GitHub::with_runner(&rec);
2903        assert!(gh.api(Path::new("."), "-evil").await.is_err());
2904        assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
2905        assert!(
2906            gh.api(Path::new("."), "").await.is_err(),
2907            "empty refused too"
2908        );
2909        assert!(rec.calls().is_empty(), "nothing may spawn");
2910    }
2911
2912    // release_create pins the empirically-verified `gh release create` argv
2913    // (gh 2.95.0): the bare `<tag>` positional plus the flag-VALUE title/notes
2914    // and presence-only --draft/--prerelease, in that order; gh prints the URL.
2915    #[tokio::test]
2916    async fn release_create_builds_argv_and_returns_url() {
2917        let rec = RecordingRunner::replying(Reply::ok("https://gh/releases/v1.2.0\n"));
2918        let gh = GitHub::with_runner(&rec);
2919        let url = gh
2920            .release_create(
2921                Path::new("/repo"),
2922                ReleaseCreate::new("v1.2.0")
2923                    .title("v1.2.0")
2924                    .notes("Notes")
2925                    .draft()
2926                    .prerelease(),
2927            )
2928            .await
2929            .expect("release_create");
2930        assert_eq!(url, "https://gh/releases/v1.2.0");
2931        let call = rec.only_call();
2932        assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
2933        assert_eq!(
2934            call.args_str(),
2935            [
2936                "release",
2937                "create",
2938                "v1.2.0",
2939                "--title",
2940                "v1.2.0",
2941                "--notes",
2942                "Notes",
2943                "--draft",
2944                "--prerelease"
2945            ]
2946        );
2947    }
2948
2949    // With only the tag, release_create emits neither the optional flags nor the
2950    // presence-only booleans — a minimal `gh release create <tag>`.
2951    #[tokio::test]
2952    async fn release_create_omits_unset_options() {
2953        let rec = RecordingRunner::replying(Reply::ok("https://gh/releases/v2\n"));
2954        let gh = GitHub::with_runner(&rec);
2955        gh.release_create(Path::new("/r"), ReleaseCreate::new("v2"))
2956            .await
2957            .expect("release_create");
2958        let call = rec.only_call();
2959        assert_eq!(call.args_str(), ["release", "create", "v2"]);
2960        assert!(!call.has_flag("--title"));
2961        assert!(!call.has_flag("--notes"));
2962        assert!(!call.has_flag("--draft"));
2963        assert!(!call.has_flag("--prerelease"));
2964    }
2965
2966    // release_delete pins `gh release delete <tag> --yes` (--yes so a headless
2967    // delete never hangs on gh's confirmation prompt).
2968    #[tokio::test]
2969    async fn release_delete_builds_argv_with_yes() {
2970        let rec = RecordingRunner::replying(Reply::ok(""));
2971        let gh = GitHub::with_runner(&rec);
2972        gh.release_delete(Path::new("/r"), "v1.2.0")
2973            .await
2974            .expect("release_delete");
2975        assert_eq!(
2976            rec.only_call().args_str(),
2977            ["release", "delete", "v1.2.0", "--yes"]
2978        );
2979    }
2980
2981    // Both release mutators guard their bare `<tag>` positional against flag-like
2982    // or empty input before anything spawns (same guard as `release_view`/`api`).
2983    #[tokio::test]
2984    async fn release_mutators_reject_flag_like_tag() {
2985        let rec = RecordingRunner::replying(Reply::ok(""));
2986        let gh = GitHub::with_runner(&rec);
2987        assert!(
2988            gh.release_create(Path::new("."), ReleaseCreate::new("-evil"))
2989                .await
2990                .is_err()
2991        );
2992        assert!(
2993            gh.release_create(Path::new("."), ReleaseCreate::new(""))
2994                .await
2995                .is_err()
2996        );
2997        assert!(gh.release_delete(Path::new("."), "-evil").await.is_err());
2998        assert!(gh.release_delete(Path::new("."), "").await.is_err());
2999        assert!(rec.calls().is_empty(), "nothing may spawn");
3000    }
3001
3002    #[tokio::test]
3003    async fn api_runs_in_the_bound_repo_dir() {
3004        let rec = RecordingRunner::replying(Reply::ok("{}\n"));
3005        let gh = GitHub::with_runner(&rec);
3006        gh.api(Path::new("/repo"), "repos/o/r/pulls")
3007            .await
3008            .expect("api");
3009        let call = rec.only_call();
3010        assert_eq!(call.args_str(), ["api", "repos/o/r/pulls"]);
3011        // H9: the request runs in the bound repo dir, so gh resolves a relative
3012        // endpoint's `{owner}/{repo}` from *that* repo — not the process cwd.
3013        assert_eq!(call.cwd, Some(std::path::PathBuf::from("/repo")));
3014    }
3015
3016    // pr_merge builds the strategy flag plus the optional --auto/--delete-branch.
3017    #[tokio::test]
3018    async fn pr_merge_builds_strategy_and_flags() {
3019        let rec = RecordingRunner::replying(Reply::ok(""));
3020        let gh = GitHub::with_runner(&rec);
3021        gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
3022            .await
3023            .expect("pr_merge");
3024        assert_eq!(
3025            rec.only_call().args_str(),
3026            ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
3027        );
3028
3029        let bare = RecordingRunner::replying(Reply::ok(""));
3030        let gh = GitHub::with_runner(&bare);
3031        gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
3032            .await
3033            .expect("pr_merge");
3034        let call = bare.only_call();
3035        assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
3036        assert!(!call.has_flag("--auto"));
3037        assert!(!call.has_flag("--delete-branch"));
3038    }
3039
3040    #[tokio::test]
3041    async fn pr_mark_ready_and_close_build_args() {
3042        let rec = RecordingRunner::replying(Reply::ok(""));
3043        let gh = GitHub::with_runner(&rec);
3044        gh.pr_mark_ready(Path::new("/r"), 3)
3045            .await
3046            .expect("pr_mark_ready");
3047        gh.pr_close(Path::new("/r"), 3, PrClose::new().delete_branch())
3048            .await
3049            .expect("close");
3050        gh.pr_close(Path::new("/r"), 4, PrClose::new())
3051            .await
3052            .expect("close");
3053        let calls = rec.calls();
3054        assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
3055        assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
3056        assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
3057    }
3058
3059    // pr_checkout maps to `pr checkout <n>` and runs in the bound repo dir.
3060    #[tokio::test]
3061    async fn pr_checkout_builds_args_in_repo_dir() {
3062        let rec = RecordingRunner::replying(Reply::ok(""));
3063        let gh = GitHub::with_runner(&rec);
3064        gh.pr_checkout(Path::new("/repo"), 7)
3065            .await
3066            .expect("pr_checkout");
3067        let call = rec.only_call();
3068        assert_eq!(call.args_str(), ["pr", "checkout", "7"]);
3069        assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
3070        // The bound view produces byte-identical argv.
3071        let rec = RecordingRunner::replying(Reply::ok(""));
3072        let gh = GitHub::with_runner(&rec);
3073        gh.at(Path::new("/repo"))
3074            .pr_checkout(7)
3075            .await
3076            .expect("pr_checkout");
3077        assert_eq!(rec.only_call().args_str(), ["pr", "checkout", "7"]);
3078    }
3079
3080    // gh signals the checks outcome via exit code (0 pass / 8 pending / 1 some
3081    // failed) but emits the same JSON for all three — all must parse. Other
3082    // exits (and timeouts) are genuine errors.
3083    #[tokio::test]
3084    async fn pr_checks_parses_all_outcome_exit_codes() {
3085        let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
3086            "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
3087        for reply in [
3088            Reply::ok(json),
3089            Reply::fail(8, "checks pending").with_stdout(json),
3090            Reply::fail(1, "some checks failed").with_stdout(json),
3091        ] {
3092            let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
3093            let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
3094            assert_eq!(checks.len(), 1);
3095            assert_eq!(checks[0].bucket, CheckBucket::Pass);
3096        }
3097
3098        // A PR with no checks at all: gh exits 1 with NO JSON and a
3099        // "no checks reported" message — an empty list, not an error. Matched
3100        // case-insensitively, so a capitalized variant is still the empty case.
3101        for stderr in [
3102            "no checks reported on the 'feat/x' branch",
3103            "No Checks Reported on the 'feat/x' branch",
3104        ] {
3105            let gh = GitHub::with_runner(
3106                ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
3107            );
3108            assert!(
3109                gh.pr_checks(Path::new("."), 7)
3110                    .await
3111                    .expect("no checks → empty")
3112                    .is_empty(),
3113                "no-checks must read as empty for stderr {stderr:?}"
3114            );
3115        }
3116        // …while a bare exit 1 for a different reason stays an error.
3117        let gh = GitHub::with_runner(ScriptedRunner::new().on(
3118            ["gh", "pr", "checks"],
3119            Reply::fail(1, "no pull requests found for branch 'feat/x'"),
3120        ));
3121        assert!(matches!(
3122            gh.pr_checks(Path::new("."), 7).await.unwrap_err().reason(),
3123            ErrorReason::Exit { .. }
3124        ));
3125
3126        // Exit 4 (auth required) is a real failure, not an outcome.
3127        let gh = GitHub::with_runner(
3128            ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
3129        );
3130        assert!(matches!(
3131            gh.pr_checks(Path::new("."), 7).await.unwrap_err().reason(),
3132            ErrorReason::Exit { .. }
3133        ));
3134
3135        let gh =
3136            GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
3137        assert!(matches!(
3138            gh.pr_checks(Path::new("."), 7).await.unwrap_err().reason(),
3139            ErrorReason::Timeout { .. }
3140        ));
3141    }
3142
3143    // Hermetic: real pr_diff() arg-building (incl. `--color never`) + the
3144    // shared unified-diff parser against canned `gh pr diff` output.
3145    #[tokio::test]
3146    async fn pr_diff_builds_args_and_parses_scripted_output() {
3147        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3148        let rec = RecordingRunner::replying(Reply::ok(out));
3149        let gh = GitHub::with_runner(&rec);
3150        let files = gh.pr_diff(Path::new("/r"), 7).await.expect("pr_diff");
3151        assert_eq!(files.len(), 1);
3152        assert_eq!(files[0].path, std::path::Path::new("m"));
3153        assert_eq!(files[0].change, ChangeKind::Modified);
3154        assert_eq!(
3155            rec.only_call().args_str(),
3156            ["pr", "diff", "7", "--color", "never"]
3157        );
3158    }
3159
3160    // T-049: `pr_diff` over the client's default OutputBudget is refused with
3161    // `OutputTooLarge` (actual + allowed sizes), never a silently truncated diff.
3162    // T-130: audited against processkit 3.0's raw-pipe-byte accounting and kept as
3163    // is — a content read captures RAW stdout, whose accounting 3.0 left untouched,
3164    // and the fixture is ~2x the ceiling under either unit. The exact boundary is
3165    // pinned in `vcs_cli_support`'s `content_budget_*` tests.
3166    #[tokio::test]
3167    async fn pr_diff_over_budget_errors_output_too_large() {
3168        let big = "diff --git a/m b/m\n".to_string() + &"+line\n".repeat(20_000);
3169        assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
3170        let gh =
3171            GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(&big)))
3172                .default_output_budget(OutputBudget::bytes(64 * 1024));
3173        match gh
3174            .pr_diff(Path::new("/r"), 7)
3175            .await
3176            .map_err(Error::into_reason)
3177        {
3178            Err(ErrorReason::OutputTooLarge {
3179                program,
3180                max_bytes,
3181                total_bytes,
3182                ..
3183            }) => {
3184                assert_eq!(program, "gh");
3185                assert_eq!(max_bytes, Some(64 * 1024));
3186                assert!(total_bytes > 64 * 1024, "actual exceeds allowed");
3187            }
3188            other => panic!("expected OutputTooLarge, got {other:?}"),
3189        }
3190    }
3191
3192    // The per-call override reads a legitimately large PR diff past the tight
3193    // client default that would otherwise refuse it.
3194    #[tokio::test]
3195    async fn pr_diff_within_override_reads_past_the_default() {
3196        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3197        let gh =
3198            GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(out)))
3199                .default_output_budget(OutputBudget::bytes(4)); // absurdly tight default
3200        assert!(matches!(
3201            err_reason(&gh.pr_diff(Path::new("/r"), 7).await),
3202            Some(ErrorReason::OutputTooLarge { .. })
3203        ));
3204        let files = gh
3205            .pr_diff_within(Path::new("/r"), 7, OutputBudget::unlimited())
3206            .await
3207            .expect("override reads the diff");
3208        assert_eq!(files.len(), 1);
3209        assert_eq!(files[0].path, std::path::Path::new("m"));
3210    }
3211
3212    // T-049: `gh run watch`'s fixed cap is reconciled onto the shared OutputBudget
3213    // as its DROP-OLDEST *diagnostic* projection — a bounded tail that NEVER turns a
3214    // long, chatty watch into `OutputTooLarge`. A watch that reprints far past the
3215    // 256 KiB / 256-line cap still succeeds and reads the final run state.
3216    // T-130: unaffected by processkit 3.0's raw-pipe-byte accounting — that change
3217    // re-based the fail-loud `OverflowMode::Error` ceiling only, while a drop-mode
3218    // buffer still bounds what it RETAINS by decoded line-content bytes. The 256 KiB
3219    // / 256-line watch cap therefore keeps exactly the tail it kept before.
3220    #[tokio::test]
3221    async fn run_watch_bounds_output_without_failing_loud() {
3222        // ~5 MiB of repeated job-table frames — well past the watch cap.
3223        let flood = "watching run… job A: running\n".repeat(180_000);
3224        let run_json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
3225            "status":"completed","conclusion":"success","workflowName":"CI",
3226            "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
3227        let gh = GitHub::with_runner(
3228            ScriptedRunner::new()
3229                .on(["gh", "run", "watch"], Reply::ok(&flood))
3230                .on(["gh", "run", "view"], Reply::ok(run_json)),
3231        );
3232        // Must NOT error out with OutputTooLarge — the diagnostic projection drops
3233        // the oldest frames and keeps going, then `run view` yields the state.
3234        let run = gh
3235            .run_watch(Path::new("/r"), 42)
3236            .await
3237            .expect("a chatty watch is bounded, not failed loud");
3238        assert_eq!(run.database_id, 42);
3239    }
3240
3241    // Each review action maps to its flag; the body is carried on the action
3242    // (approve's is optional and omitted when absent).
3243    #[tokio::test]
3244    async fn pr_review_builds_action_args() {
3245        let rec = RecordingRunner::replying(Reply::ok(""));
3246        let gh = GitHub::with_runner(&rec);
3247        gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
3248            .await
3249            .expect("approve");
3250        gh.pr_review(
3251            Path::new("/r"),
3252            7,
3253            ReviewAction::request_changes("fix the parser"),
3254        )
3255        .await
3256        .expect("request changes");
3257        gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
3258            .await
3259            .expect("comment");
3260        let calls = rec.calls();
3261        assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
3262        assert!(!calls[0].has_flag("--body"));
3263        assert_eq!(
3264            calls[1].args_str(),
3265            [
3266                "pr",
3267                "review",
3268                "7",
3269                "--request-changes",
3270                "--body",
3271                "fix the parser"
3272            ]
3273        );
3274        assert_eq!(
3275            calls[2].args_str(),
3276            ["pr", "review", "7", "--comment", "--body", "nice"]
3277        );
3278    }
3279
3280    // `approve().with_body(..)` attaches the optional approve message, emitting
3281    // `--approve --body <body>`; the accessors read the parts back.
3282    #[tokio::test]
3283    async fn pr_review_approve_with_body() {
3284        let action = ReviewAction::approve().with_body("LGTM");
3285        assert_eq!(action.kind(), ReviewKind::Approve);
3286        assert_eq!(action.body(), Some("LGTM"));
3287
3288        let rec = RecordingRunner::replying(Reply::ok(""));
3289        let gh = GitHub::with_runner(&rec);
3290        gh.pr_review(Path::new("/r"), 7, action)
3291            .await
3292            .expect("approve with body");
3293        assert_eq!(
3294            rec.only_call().args_str(),
3295            ["pr", "review", "7", "--approve", "--body", "LGTM"]
3296        );
3297    }
3298
3299    #[tokio::test]
3300    async fn pr_comment_and_issue_create_return_urls() {
3301        let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
3302        let gh = GitHub::with_runner(&rec);
3303        assert_eq!(
3304            gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
3305            "https://gh/x"
3306        );
3307        assert_eq!(
3308            gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
3309            "https://gh/x"
3310        );
3311        let calls = rec.calls();
3312        assert_eq!(
3313            calls[0].args_str(),
3314            ["pr", "comment", "7", "--body", "hello"]
3315        );
3316        assert_eq!(
3317            calls[1].args_str(),
3318            ["issue", "create", "--title", "T", "--body", "B"]
3319        );
3320    }
3321
3322    // `issue close`/`issue reopen` take only the bare `u64` index (no flags, no
3323    // structured output); `issue comment` puts the body in a flag-VALUE `--body`
3324    // slot and returns the new comment's URL.
3325    #[tokio::test]
3326    async fn issue_close_reopen_and_comment_build_argv() {
3327        let rec = RecordingRunner::replying(Reply::ok("https://gh/i/7#c1\n"));
3328        let gh = GitHub::with_runner(&rec);
3329
3330        gh.issue_close(Path::new("/r"), 7).await.expect("close");
3331        gh.issue_reopen(Path::new("/r"), 7).await.expect("reopen");
3332        assert_eq!(
3333            gh.issue_comment(Path::new("/r"), 7, "ping").await.unwrap(),
3334            "https://gh/i/7#c1"
3335        );
3336
3337        let calls = rec.calls();
3338        assert_eq!(calls[0].args_str(), ["issue", "close", "7"]);
3339        assert_eq!(calls[1].args_str(), ["issue", "reopen", "7"]);
3340        assert_eq!(
3341            calls[2].args_str(),
3342            ["issue", "comment", "7", "--body", "ping"]
3343        );
3344    }
3345
3346    // The comment body rides in a flag-VALUE slot, so gh consumes a leading-`-`
3347    // body verbatim (a Markdown bullet list / `---` rule is legitimate) — the
3348    // argv is pinned to prove no guard mangles or rejects it.
3349    #[tokio::test]
3350    async fn issue_comment_passes_leading_dash_body_verbatim() {
3351        let rec = RecordingRunner::replying(Reply::ok("https://gh/i/7#c2\n"));
3352        let gh = GitHub::with_runner(&rec);
3353        gh.issue_comment(Path::new("/r"), 7, "- a bullet")
3354            .await
3355            .expect("dash body");
3356        assert_eq!(
3357            rec.only_call().args_str(),
3358            ["issue", "comment", "7", "--body", "- a bullet"]
3359        );
3360    }
3361
3362    // pr_edit emits only the flags the caller set. The flag-VALUE slots
3363    // (`--title <t>`, `--body <b>`) are passed verbatim — no argv-guard needed
3364    // since gh consumes the next token as a value, not as a flag.
3365    #[tokio::test]
3366    async fn pr_edit_emits_only_provided_fields() {
3367        let rec = RecordingRunner::replying(Reply::ok(""));
3368        let gh = GitHub::with_runner(&rec);
3369
3370        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
3371            .await
3372            .expect("title-only edit");
3373        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
3374            .await
3375            .expect("body-only edit");
3376        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
3377            .await
3378            .expect("both-fields edit");
3379
3380        let calls = rec.calls();
3381        assert_eq!(
3382            calls[0].args_str(),
3383            ["pr", "edit", "7", "--title", "New title"]
3384        );
3385        assert_eq!(
3386            calls[1].args_str(),
3387            ["pr", "edit", "7", "--body", "New body"]
3388        );
3389        assert_eq!(
3390            calls[2].args_str(),
3391            ["pr", "edit", "7", "--title", "T", "--body", "B"]
3392        );
3393    }
3394
3395    // An empty string is a real value (clears the field) — it must reach the
3396    // CLI as `--title ""`, not be silently dropped. The argv is asserted
3397    // byte-for-byte so a future "treat empty as None" regression would
3398    // surface here.
3399    #[tokio::test]
3400    async fn pr_edit_some_empty_string_clears_field() {
3401        let rec = RecordingRunner::replying(Reply::ok(""));
3402        let gh = GitHub::with_runner(&rec);
3403        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
3404            .await
3405            .expect("empty title");
3406        assert_eq!(
3407            rec.only_call().args_str(),
3408            ["pr", "edit", "7", "--title", ""]
3409        );
3410    }
3411
3412    #[tokio::test]
3413    async fn with_credentials_injects_gh_token_and_default_does_not() {
3414        // With a provider: the token is set as GH_TOKEN on the command — and never
3415        // appears in argv (so it can't leak through `ps`).
3416        let rec = RecordingRunner::replying(Reply::ok("[]"));
3417        let gh = GitHub::with_runner(&rec)
3418            .with_credentials(Arc::new(StaticCredential::token("tok-123")));
3419        gh.pr_list(Path::new("/r")).await.unwrap();
3420        let call = rec.only_call();
3421        let token = call
3422            .envs
3423            .iter()
3424            .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
3425            .and_then(|(_, v)| v.as_ref())
3426            .and_then(|v| v.to_str());
3427        assert_eq!(
3428            token,
3429            Some("tok-123"),
3430            "provider token injected as GH_TOKEN"
3431        );
3432        assert!(
3433            !call.args_str().iter().any(|a| a.contains("tok-123")),
3434            "secret must never appear in argv"
3435        );
3436
3437        // Without a provider: no GH_TOKEN injected — ambient `gh` auth is unchanged.
3438        let rec = RecordingRunner::replying(Reply::ok("[]"));
3439        let gh = GitHub::with_runner(&rec);
3440        gh.pr_list(Path::new("/r")).await.unwrap();
3441        assert!(
3442            !rec.only_call()
3443                .envs
3444                .iter()
3445                .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
3446            "no provider → no token env (ambient gh auth)"
3447        );
3448    }
3449
3450    // The `with_token` convenience is the common path: a static token, no `Arc`/
3451    // `StaticCredential` ceremony, injected as GH_TOKEN.
3452    #[tokio::test]
3453    async fn with_token_convenience_injects_gh_token() {
3454        let rec = RecordingRunner::replying(Reply::ok("[]"));
3455        let gh = GitHub::with_runner(&rec).with_token("tok-conv");
3456        gh.pr_list(Path::new("/r")).await.unwrap();
3457        let call = rec.only_call();
3458        let token = call
3459            .envs
3460            .iter()
3461            .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
3462            .and_then(|(_, v)| v.as_ref())
3463            .and_then(|v| v.to_str());
3464        assert_eq!(token, Some("tok-conv"));
3465    }
3466
3467    // A provider that yields `Ok(None)` defers to ambient auth: no GH_TOKEN is
3468    // injected, exactly as if no provider were attached. Pins the None=ambient
3469    // contract end-to-end (not just at the provider level).
3470    #[tokio::test]
3471    async fn provider_returning_none_falls_back_to_ambient() {
3472        let rec = RecordingRunner::replying(Reply::ok("[]"));
3473        let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
3474        gh.pr_list(Path::new("/r")).await.unwrap();
3475        assert!(
3476            !rec.only_call()
3477                .envs
3478                .iter()
3479                .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
3480            "Ok(None) provider injects no token (ambient)"
3481        );
3482    }
3483
3484    #[tokio::test]
3485    async fn injected_token_overrides_ambient_default_env() {
3486        // A provider token is applied after any `default_env("GH_TOKEN", …)`, so it
3487        // wins — "I supplied a provider, use it" beats an ambient env default.
3488        let rec = RecordingRunner::replying(Reply::ok("[]"));
3489        let gh = GitHub::with_runner(&rec)
3490            .default_env("GH_TOKEN", "ambient-token")
3491            .with_credentials(Arc::new(StaticCredential::token("provider-token")));
3492        gh.pr_list(Path::new("/r")).await.unwrap();
3493        let call = rec.only_call();
3494        let winner = call
3495            .envs
3496            .iter()
3497            .rev()
3498            .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
3499            .and_then(|(_, v)| v.as_ref())
3500            .and_then(|v| v.to_str());
3501        assert_eq!(winner, Some("provider-token"), "provider token wins");
3502    }
3503
3504    // --- Enterprise host + host-scoped auth (T-046) ------------------------
3505
3506    // GitHubHost classifies github.com (any case) as SaaS and every other valid
3507    // host as GHES, canonicalizing to a lower-cased hostname.
3508    #[test]
3509    fn github_host_classifies_saas_and_enterprise() {
3510        let saas = GitHubHost::github_com();
3511        assert!(saas.is_github_com() && !saas.is_enterprise());
3512        assert_eq!(saas.as_str(), "github.com");
3513
3514        for h in ["github.com", "GitHub.com", "GITHUB.COM"] {
3515            let host = GitHubHost::new(h).unwrap();
3516            assert!(host.is_github_com(), "{h} should classify as SaaS");
3517            assert_eq!(host.as_str(), "github.com", "canonicalized to lower-case");
3518        }
3519
3520        let ghes = GitHubHost::new("GHE.Example.COM").unwrap();
3521        assert!(ghes.is_enterprise());
3522        assert_eq!(ghes.as_str(), "ghe.example.com");
3523    }
3524
3525    // A malformed hostname is a diagnosable invalid-input error, not a silent
3526    // github.com guess — so a bad host can't quietly become the SaaS default.
3527    #[test]
3528    fn github_host_new_rejects_malformed_hosts() {
3529        for bad in [
3530            "",
3531            "  ",
3532            "-evil",
3533            "has space",
3534            "https://github.com",
3535            "github.com/owner",
3536            "ghe.example.com:8443",
3537            "user@github.com",
3538            ".leading",
3539            "trailing.",
3540        ] {
3541            let err = GitHubHost::new(bad).unwrap_err();
3542            assert!(
3543                vcs_cli_support::is_invalid_input(&err),
3544                "{bad:?} should be rejected as invalid input, got {err:?}"
3545            );
3546        }
3547    }
3548
3549    // from_remote_url derives + classifies the host across HTTPS / SSH / scp-like
3550    // remotes, dropping userinfo and port.
3551    #[test]
3552    fn github_host_from_remote_url_parses_and_classifies() {
3553        let cases = [
3554            ("https://github.com/o/r.git", "github.com", false),
3555            (
3556                "https://x-access-token:tok@ghe.example.com:8443/o/r",
3557                "ghe.example.com",
3558                true,
3559            ),
3560            ("http://ghe.internal.corp/o/r", "ghe.internal.corp", true),
3561            ("ssh://git@github.com/o/r", "github.com", false),
3562            ("ssh://git@ghe.example.com:22/o/r", "ghe.example.com", true),
3563            ("git@github.com:o/r.git", "github.com", false),
3564            ("git@ghe.example.com:o/r.git", "ghe.example.com", true),
3565        ];
3566        for (url, host, enterprise) in cases {
3567            let parsed =
3568                GitHubHost::from_remote_url(url).unwrap_or_else(|e| panic!("parse {url}: {e:?}"));
3569            assert_eq!(parsed.as_str(), host, "host for {url}");
3570            assert_eq!(parsed.is_enterprise(), enterprise, "class for {url}");
3571        }
3572    }
3573
3574    // An unparseable / hostless / ambiguous remote is a diagnosable error, never a
3575    // silent github.com fallback (which would authenticate the wrong host).
3576    #[test]
3577    fn github_host_from_remote_url_rejects_ambiguous() {
3578        for url in [
3579            "",
3580            "   ",
3581            "not-a-url",
3582            "https://",
3583            "ssh://",
3584            "git@internalhost:repo.git",
3585            "C:\\repo\\path",
3586            "https://[::1]:8443/x",
3587        ] {
3588            let err = GitHubHost::from_remote_url(url).unwrap_err();
3589            assert!(
3590                vcs_cli_support::is_invalid_input(&err),
3591                "{url:?} should be a diagnosable error, got {err:?}"
3592            );
3593        }
3594    }
3595
3596    // Binding a github.com host injects the credential as GH_TOKEN (the SaaS
3597    // default) and pins GH_HOST — never the enterprise env.
3598    #[tokio::test]
3599    async fn with_host_github_com_injects_gh_token() {
3600        let rec = RecordingRunner::replying(Reply::ok("[]"));
3601        let gh = GitHub::with_runner(&rec)
3602            .with_host(GitHubHost::github_com())
3603            .with_token("saas-tok");
3604        gh.pr_list(Path::new("/r")).await.unwrap();
3605        let call = rec.only_call();
3606        assert!(call.env_is("GH_TOKEN", "saas-tok"));
3607        assert!(
3608            !call.has_env("GH_ENTERPRISE_TOKEN"),
3609            "github.com must not touch the enterprise token env"
3610        );
3611        assert!(call.env_is("GH_HOST", "github.com"));
3612        assert!(!call.args_str().iter().any(|a| a.contains("saas-tok")));
3613    }
3614
3615    // Binding a GHES host injects the credential as GH_ENTERPRISE_TOKEN — the env
3616    // gh reads for a non-github.com host — plus GH_HOST, and NEVER as GH_TOKEN, so
3617    // an enterprise secret can't leak into the github.com token env. The secret
3618    // stays out of argv.
3619    #[tokio::test]
3620    async fn with_host_enterprise_injects_enterprise_token_and_host() {
3621        let rec = RecordingRunner::replying(Reply::ok("[]"));
3622        let gh = GitHub::with_runner(&rec)
3623            .with_host(GitHubHost::new("ghe.example.com").unwrap())
3624            .with_token("ent-tok");
3625        gh.pr_list(Path::new("/r")).await.unwrap();
3626        let call = rec.only_call();
3627        assert!(call.env_is("GH_ENTERPRISE_TOKEN", "ent-tok"));
3628        assert!(
3629            !call.has_env("GH_TOKEN"),
3630            "enterprise token must not land in the github.com env"
3631        );
3632        assert!(call.env_is("GH_HOST", "ghe.example.com"));
3633        assert!(
3634            !call.args_str().iter().any(|a| a.contains("ent-tok")),
3635            "secret must never appear in argv"
3636        );
3637    }
3638
3639    // A host-bound client with NO provider injects no token at all (ambient gh
3640    // login for that host) but still pins GH_HOST, so gh targets the right server.
3641    #[tokio::test]
3642    async fn with_host_enterprise_without_credentials_is_ambient() {
3643        let rec = RecordingRunner::replying(Reply::ok("[]"));
3644        let gh = GitHub::with_runner(&rec).with_host(GitHubHost::new("ghe.corp.example").unwrap());
3645        gh.pr_list(Path::new("/r")).await.unwrap();
3646        let call = rec.only_call();
3647        assert!(!call.has_env("GH_ENTERPRISE_TOKEN"));
3648        assert!(!call.has_env("GH_TOKEN"));
3649        assert!(call.env_is("GH_HOST", "ghe.corp.example"));
3650    }
3651
3652    // Several hosts, one client each: every client injects only its own host's
3653    // token/env — a credential for one host never leaks into another.
3654    #[tokio::test]
3655    async fn multiple_hosts_inject_independently() {
3656        let rec_a = RecordingRunner::replying(Reply::ok("[]"));
3657        GitHub::with_runner(&rec_a)
3658            .with_host(GitHubHost::new("ghe.a.example").unwrap())
3659            .with_token("tok-a")
3660            .pr_list(Path::new("/r"))
3661            .await
3662            .unwrap();
3663
3664        let rec_b = RecordingRunner::replying(Reply::ok("[]"));
3665        GitHub::with_runner(&rec_b)
3666            .with_host(GitHubHost::new("ghe.b.example").unwrap())
3667            .with_token("tok-b")
3668            .pr_list(Path::new("/r"))
3669            .await
3670            .unwrap();
3671
3672        let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
3673        GitHub::with_runner(&rec_saas)
3674            .with_host(GitHubHost::github_com())
3675            .with_token("tok-saas")
3676            .pr_list(Path::new("/r"))
3677            .await
3678            .unwrap();
3679
3680        let ca = rec_a.only_call();
3681        assert!(ca.env_is("GH_ENTERPRISE_TOKEN", "tok-a") && ca.env_is("GH_HOST", "ghe.a.example"));
3682        assert!(
3683            !ca.args_str()
3684                .iter()
3685                .any(|s| s.contains("tok-b") || s.contains("tok-saas")),
3686            "host A must not carry another host's secret"
3687        );
3688
3689        let cb = rec_b.only_call();
3690        assert!(cb.env_is("GH_ENTERPRISE_TOKEN", "tok-b") && cb.env_is("GH_HOST", "ghe.b.example"));
3691
3692        let cs = rec_saas.only_call();
3693        assert!(cs.env_is("GH_TOKEN", "tok-saas") && cs.env_is("GH_HOST", "github.com"));
3694        assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
3695    }
3696
3697    // A HOST-KEYED provider on a host-bound client injects ONLY that host's secret,
3698    // into the env gh reads for it — and a client bound to a *different* host draws a
3699    // different secret from the SAME provider, so one instance's token never lands in
3700    // another's command. (T-045: the bound host now reaches the CredentialRequest, so
3701    // the provider can tell SaaS from a self-hosted GHES instance.)
3702    #[tokio::test]
3703    async fn host_keyed_provider_injects_only_the_bound_hosts_token() {
3704        // Typed as the trait object so `Arc::clone` yields `Arc<dyn …>` directly
3705        // (the unsized coercion doesn't flow back through `Arc::clone`'s inference).
3706        let provider: Arc<dyn CredentialProvider> =
3707            Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
3708                Ok(match r.host {
3709                    Some("github.com") => Some(Credential::token("saas-secret")),
3710                    Some("ghe.example.com") => Some(Credential::token("ent-secret")),
3711                    _ => None,
3712                })
3713            }));
3714
3715        // SaaS client → GH_TOKEN carries the github.com secret, never the ent one.
3716        let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
3717        GitHub::with_runner(&rec_saas)
3718            .with_host(GitHubHost::github_com())
3719            .with_credentials(Arc::clone(&provider))
3720            .pr_list(Path::new("/r"))
3721            .await
3722            .unwrap();
3723        let cs = rec_saas.only_call();
3724        assert!(cs.env_is("GH_TOKEN", "saas-secret"));
3725        assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
3726        assert!(!cs.args_str().iter().any(|a| a.contains("saas-secret")));
3727
3728        // Enterprise client → the ENT secret in GH_ENTERPRISE_TOKEN only, from the
3729        // very same provider; the github.com token env is untouched.
3730        let rec_ent = RecordingRunner::replying(Reply::ok("[]"));
3731        GitHub::with_runner(&rec_ent)
3732            .with_host(GitHubHost::new("ghe.example.com").unwrap())
3733            .with_credentials(Arc::clone(&provider))
3734            .pr_list(Path::new("/r"))
3735            .await
3736            .unwrap();
3737        let ce = rec_ent.only_call();
3738        assert!(ce.env_is("GH_ENTERPRISE_TOKEN", "ent-secret"));
3739        assert!(
3740            !ce.has_env("GH_TOKEN"),
3741            "the enterprise command must not carry the github.com token env"
3742        );
3743        assert!(!ce.args_str().iter().any(|a| a.contains("ent-secret")));
3744    }
3745
3746    // Fallback policy, read vs write — `Ok(None)` (a host-keyed provider with nothing
3747    // for this host) leaves the command on ambient gh auth (no token env injected)
3748    // for BOTH a read (`pr_list`) and a write (`pr_merge`). (T-045)
3749    #[tokio::test]
3750    async fn provider_none_defers_to_ambient_for_read_and_write() {
3751        let rec_read = RecordingRunner::replying(Reply::ok("[]"));
3752        GitHub::with_runner(&rec_read)
3753            .with_host(GitHubHost::github_com())
3754            .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
3755            .pr_list(Path::new("/r"))
3756            .await
3757            .unwrap();
3758        let cr = rec_read.only_call();
3759        assert!(
3760            !cr.has_env("GH_TOKEN") && !cr.has_env("GH_ENTERPRISE_TOKEN"),
3761            "read defers to ambient on Ok(None)"
3762        );
3763
3764        let rec_write = RecordingRunner::replying(Reply::ok(""));
3765        GitHub::with_runner(&rec_write)
3766            .with_host(GitHubHost::github_com())
3767            .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
3768            .pr_merge(Path::new("/r"), 7, PrMerge::squash())
3769            .await
3770            .unwrap();
3771        let cw = rec_write.only_call();
3772        assert!(
3773            !cw.has_env("GH_TOKEN") && !cw.has_env("GH_ENTERPRISE_TOKEN"),
3774            "write defers to ambient on Ok(None)"
3775        );
3776    }
3777
3778    // Fallback policy, read vs write — a provider `Err` is FAIL-CLOSED: it aborts the
3779    // operation rather than silently running on ambient auth, proven separately for a
3780    // read (`pr_list`) and a write (`pr_merge`). gh is never spawned: the error
3781    // surfaces in `prepare`, before the process. (T-045)
3782    #[tokio::test]
3783    async fn provider_error_aborts_read_and_write_fail_closed() {
3784        fn boom() -> Arc<dyn CredentialProvider> {
3785            Arc::new(provider_fn(|_r: &CredentialRequest<'_>| {
3786                Err(Error::spawn(
3787                    BINARY,
3788                    std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
3789                ))
3790            }))
3791        }
3792
3793        let rec_read = RecordingRunner::replying(Reply::ok("[]"));
3794        let read = GitHub::with_runner(&rec_read)
3795            .with_host(GitHubHost::github_com())
3796            .with_credentials(boom())
3797            .pr_list(Path::new("/r"))
3798            .await;
3799        assert!(read.is_err(), "a provider error must abort the read");
3800        assert!(
3801            rec_read.calls().is_empty(),
3802            "gh must not spawn when the provider errored (read)"
3803        );
3804
3805        let rec_write = RecordingRunner::replying(Reply::ok(""));
3806        let write = GitHub::with_runner(&rec_write)
3807            .with_host(GitHubHost::github_com())
3808            .with_credentials(boom())
3809            .pr_merge(Path::new("/r"), 7, PrMerge::squash())
3810            .await;
3811        assert!(write.is_err(), "a provider error must abort the write");
3812        assert!(
3813            rec_write.calls().is_empty(),
3814            "gh must not spawn when the provider errored (write)"
3815        );
3816    }
3817
3818    // auth_status_for pins `--hostname <host>` and reflects the exit code as a bool.
3819    #[tokio::test]
3820    async fn auth_status_for_scopes_to_hostname() {
3821        let rec = RecordingRunner::replying(Reply::ok(""));
3822        let gh = GitHub::with_runner(&rec);
3823        let host = GitHubHost::new("ghe.example.com").unwrap();
3824        assert!(gh.auth_status_for(&host).await.unwrap());
3825        assert_eq!(
3826            rec.only_call().args_str(),
3827            ["auth", "status", "--hostname", "ghe.example.com"]
3828        );
3829    }
3830
3831    // The scoped probe reports the TARGET host truthfully even when a DIFFERENT
3832    // host's session is broken — no false negative from the aggregate `gh auth
3833    // status` that the unscoped `auth_status` would fold together.
3834    #[tokio::test]
3835    async fn auth_status_for_is_independent_of_other_host_sessions() {
3836        let runner = ScriptedRunner::new()
3837            .on(
3838                ["gh", "auth", "status", "--hostname", "broken.example.com"],
3839                Reply::fail(1, "not logged in to broken.example.com"),
3840            )
3841            .on(
3842                ["gh", "auth", "status", "--hostname", "good.example.com"],
3843                Reply::ok(""),
3844            );
3845        let gh = GitHub::with_runner(runner);
3846        assert!(
3847            gh.auth_status_for(&GitHubHost::new("good.example.com").unwrap())
3848                .await
3849                .unwrap(),
3850            "the healthy target host reads as authenticated"
3851        );
3852        assert!(
3853            !gh.auth_status_for(&GitHubHost::new("broken.example.com").unwrap())
3854                .await
3855                .unwrap(),
3856            "a broken host reads as not authenticated, independently"
3857        );
3858    }
3859
3860    // The bound view forwards auth_status_for verbatim (a bare, dir-independent
3861    // method): byte-identical argv, no cwd bound.
3862    #[tokio::test]
3863    async fn bound_view_auth_status_for_matches_client() {
3864        let rec = RecordingRunner::replying(Reply::ok(""));
3865        let gh = GitHub::with_runner(&rec);
3866        gh.at(Path::new("/repo"))
3867            .auth_status_for(&GitHubHost::github_com())
3868            .await
3869            .unwrap();
3870        let call = rec.only_call();
3871        assert_eq!(
3872            call.args_str(),
3873            ["auth", "status", "--hostname", "github.com"]
3874        );
3875        assert_eq!(call.cwd.as_deref(), None, "bare method binds no cwd");
3876    }
3877
3878    #[tokio::test]
3879    async fn pr_feedback_requests_reviews_and_comments() {
3880        let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
3881            "body":"","submittedAt":""}],"comments":[]}"#;
3882        let rec =
3883            RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
3884        let gh = GitHub::with_runner(&rec);
3885        let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
3886        assert_eq!(feedback.reviews[0].author, "a");
3887        assert!(feedback.comments.is_empty());
3888        assert_eq!(
3889            rec.only_call().args_str(),
3890            ["pr", "view", "7", "--json", "reviews,comments"]
3891        );
3892    }
3893
3894    // run_list appends --branch only when given one.
3895    #[tokio::test]
3896    async fn run_list_appends_branch_only_when_some() {
3897        let rec = RecordingRunner::replying(Reply::ok("[]"));
3898        let gh = GitHub::with_runner(&rec);
3899        gh.run_list(Path::new("/r"), 5, None).await.expect("list");
3900        gh.run_list(Path::new("/r"), 5, Some("main".into()))
3901            .await
3902            .expect("list");
3903        let calls = rec.calls();
3904        assert_eq!(
3905            calls[0].args_str(),
3906            ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
3907        );
3908        assert_eq!(
3909            calls[1].args_str(),
3910            [
3911                "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
3912            ]
3913        );
3914    }
3915
3916    #[tokio::test]
3917    async fn workflow_list_builds_default_and_disabled_inclusive_argv() {
3918        let rec = RecordingRunner::replying(Reply::ok("[]"));
3919        let gh = GitHub::with_runner(&rec);
3920        gh.workflow_list(Path::new("/r")).await.expect("list");
3921        gh.at(Path::new("/r"))
3922            .workflow_list_with(WorkflowList::new().all().limit(75))
3923            .await
3924            .expect("list all");
3925
3926        let calls = rec.calls();
3927        assert_eq!(
3928            calls[0].args_str(),
3929            [
3930                "workflow",
3931                "list",
3932                "--limit",
3933                "50",
3934                "--json",
3935                WORKFLOW_FIELDS
3936            ]
3937        );
3938        assert_eq!(
3939            calls[1].args_str(),
3940            [
3941                "workflow",
3942                "list",
3943                "--limit",
3944                "75",
3945                "--all",
3946                "--json",
3947                WORKFLOW_FIELDS
3948            ]
3949        );
3950        assert_eq!(calls[1].cwd.as_deref(), Some(Path::new("/r")));
3951    }
3952
3953    #[tokio::test]
3954    async fn workflow_list_rejects_zero_limit_before_spawn() {
3955        let rec = RecordingRunner::replying(Reply::ok("[]"));
3956        let err = GitHub::with_runner(&rec)
3957            .workflow_list_with(Path::new("/r"), WorkflowList::new().limit(0))
3958            .await
3959            .unwrap_err();
3960        assert!(vcs_cli_support::is_invalid_input(&err));
3961        assert!(rec.calls().is_empty());
3962    }
3963
3964    #[tokio::test]
3965    async fn workflow_view_resolves_id_name_filename_and_path_from_json_inventory() {
3966        let json = r#"[
3967            {"id":17,"name":"CI","path":".github/workflows/ci.yml","state":"active"},
3968            {"id":18,"name":"Deploy","path":".github/workflows/deploy.yaml","state":"disabled_manually"}
3969        ]"#;
3970        let rec = RecordingRunner::new(
3971            ScriptedRunner::new().on(["gh", "workflow", "list"], Reply::ok(json)),
3972        );
3973        let gh = GitHub::with_runner(&rec);
3974
3975        assert_eq!(
3976            gh.workflow_view(Path::new("/r"), "17").await.unwrap().id,
3977            17
3978        );
3979        assert_eq!(
3980            gh.workflow_view(Path::new("/r"), "ci").await.unwrap().id,
3981            17
3982        );
3983        assert_eq!(
3984            gh.workflow_view(Path::new("/r"), "deploy.yaml")
3985                .await
3986                .unwrap()
3987                .id,
3988            18
3989        );
3990        assert_eq!(
3991            gh.workflow_view(Path::new("/r"), ".github/workflows/ci.yml")
3992                .await
3993                .unwrap()
3994                .id,
3995            17
3996        );
3997
3998        for call in rec.calls() {
3999            assert_eq!(
4000                call.args_str(),
4001                [
4002                    "workflow",
4003                    "list",
4004                    "--limit",
4005                    WORKFLOW_VIEW_LOOKUP_LIMIT.to_string().as_str(),
4006                    "--all",
4007                    "--json",
4008                    WORKFLOW_FIELDS
4009                ]
4010            );
4011        }
4012    }
4013
4014    #[tokio::test]
4015    async fn workflow_view_reports_empty_missing_and_ambiguous_selectors() {
4016        let rec = RecordingRunner::replying(Reply::ok(
4017            r#"[
4018                {"id":17,"name":"CI","path":".github/workflows/ci.yml","state":"active"},
4019                {"id":18,"name":"ci","path":".github/workflows/other.yml","state":"active"}
4020            ]"#,
4021        ));
4022        let gh = GitHub::with_runner(&rec);
4023
4024        let empty = gh.workflow_view(Path::new("/r"), "").await.unwrap_err();
4025        assert!(vcs_cli_support::is_invalid_input(&empty));
4026        assert!(rec.calls().is_empty(), "empty selector must not spawn");
4027
4028        for selector in ["missing", "CI"] {
4029            assert!(matches!(
4030                gh.workflow_view(Path::new("/r"), selector)
4031                    .await
4032                    .unwrap_err()
4033                    .reason(),
4034                ErrorReason::Parse { .. }
4035            ));
4036        }
4037        assert_eq!(rec.calls().len(), 2);
4038    }
4039
4040    // run_watch blocks on `run watch` (no `--exit-status`, so a failed run still
4041    // exits 0 — the outcome is read via the follow-up view, the only channel
4042    // that can distinguish failed from cancelled).
4043    #[tokio::test]
4044    async fn run_watch_then_views_final_state() {
4045        let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
4046            "status":"completed","conclusion":"failure","workflowName":"CI",
4047            "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
4048        let rec = RecordingRunner::new(
4049            ScriptedRunner::new()
4050                .on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
4051                .on(["gh", "run", "view"], Reply::ok(json)),
4052        );
4053        let gh = GitHub::with_runner(&rec);
4054        let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
4055        assert_eq!(run.conclusion, "failure");
4056        let calls = rec.calls();
4057        assert_eq!(calls.len(), 2);
4058        assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
4059        assert_eq!(
4060            calls[1].args_str(),
4061            ["run", "view", "42", "--json", RUN_FIELDS]
4062        );
4063    }
4064
4065    // A timed-out or failing watch must error — NOT report a half-finished run
4066    // via the follow-up view. (`output_string` does not error on a timeout; the
4067    // `ensure_success` in run_watch is what surfaces it.)
4068    #[tokio::test]
4069    async fn run_watch_surfaces_timeout_and_watch_errors() {
4070        let rec = RecordingRunner::new(
4071            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
4072        );
4073        let gh = GitHub::with_runner(&rec);
4074        assert!(matches!(
4075            gh.run_watch(Path::new("."), 42).await.unwrap_err().reason(),
4076            ErrorReason::Timeout { .. }
4077        ));
4078        assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
4079
4080        let gh = GitHub::with_runner(
4081            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
4082        );
4083        assert!(matches!(
4084            gh.run_watch(Path::new("."), 42).await.unwrap_err().reason(),
4085            ErrorReason::Exit { .. }
4086        ));
4087    }
4088
4089    // ProcessKit 3.1's watchdog makes a quiet `gh run watch` fail promptly instead
4090    // of leaving the caller parked forever; a chatty watch is unaffected.
4091    #[tokio::test(start_paused = true)]
4092    async fn run_watch_times_out_after_output_inactivity() {
4093        let gh =
4094            GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()));
4095        match gh.run_watch(Path::new("."), 42).await.unwrap_err().reason() {
4096            ErrorReason::Timeout {
4097                timeout,
4098                inactivity,
4099                ..
4100            } => {
4101                assert_eq!(*timeout, RUN_WATCH_INACTIVITY_TIMEOUT);
4102                assert!(*inactivity);
4103            }
4104            other => panic!("expected output-inactivity timeout, got {other:?}"),
4105        }
4106    }
4107
4108    // Client-level cancellation (processkit 0.8 `cancellation` feature): a client
4109    // built with `default_cancel_on(token)` threads the token into every command it
4110    // builds. It still wins over the 3.1 output-inactivity watchdog, so a controller
4111    // can cancel a long watch without touching the call site (zero new vcs-* API).
4112    #[tokio::test(start_paused = true)]
4113    async fn run_watch_cancels_via_client_default_token() {
4114        use processkit::CancellationToken;
4115        let token = CancellationToken::new();
4116        let gh =
4117            GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
4118                .default_cancel_on(token.clone());
4119        let call = gh.run_watch(Path::new("."), 42);
4120        tokio::pin!(call);
4121        assert!(
4122            tokio::time::timeout(Duration::from_secs(1), &mut call)
4123                .await
4124                .is_err(),
4125            "run_watch must remain pending until cancellation or its inactivity deadline"
4126        );
4127        token.cancel();
4128        match call.await.map_err(Error::into_reason) {
4129            Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
4130            other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
4131        }
4132    }
4133
4134    // workflow_dispatch with a ref and two inputs pins the empirically-verified
4135    // `gh workflow run` argv (gh 2.95.0): the bare `<workflow>` positional, then
4136    // `--ref <ref>`, then each input as `--raw-field key=value` in the order added.
4137    // `--raw-field` (not `--field`) is deliberate — `--field`'s `@value` reads a
4138    // file, so the raw form keeps an arbitrary input value a literal string.
4139    #[tokio::test]
4140    async fn workflow_dispatch_builds_argv_with_ref_and_inputs() {
4141        let rec = RecordingRunner::replying(Reply::ok(""));
4142        let gh = GitHub::with_runner(&rec);
4143        gh.workflow_dispatch(
4144            Path::new("/repo"),
4145            WorkflowDispatch::new("release.yml")
4146                .git_ref("main")
4147                .field("name", "scully")
4148                .field("greeting", "hello"),
4149        )
4150        .await
4151        .expect("workflow_dispatch");
4152        let call = rec.only_call();
4153        assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
4154        assert_eq!(
4155            call.args_str(),
4156            [
4157                "workflow",
4158                "run",
4159                "release.yml",
4160                "--ref",
4161                "main",
4162                "--raw-field",
4163                "name=scully",
4164                "--raw-field",
4165                "greeting=hello",
4166            ]
4167        );
4168    }
4169
4170    // With only the workflow selector, neither `--ref` nor any `--raw-field` is
4171    // emitted — a minimal `gh workflow run <workflow>`. A value beginning with `-`
4172    // rides safely in the `--raw-field` flag-VALUE slot (proving it is not guarded
4173    // away like a bare positional would be).
4174    #[tokio::test]
4175    async fn workflow_dispatch_omits_unset_ref_and_allows_dash_value() {
4176        let rec = RecordingRunner::replying(Reply::ok(""));
4177        let gh = GitHub::with_runner(&rec);
4178        gh.workflow_dispatch(Path::new("/r"), WorkflowDispatch::new("ci.yml"))
4179            .await
4180            .expect("workflow_dispatch");
4181        assert_eq!(rec.calls()[0].args_str(), ["workflow", "run", "ci.yml"]);
4182
4183        // A leading-`-` input VALUE is legitimate and passed verbatim.
4184        let rec = RecordingRunner::replying(Reply::ok(""));
4185        let gh = GitHub::with_runner(&rec);
4186        gh.workflow_dispatch(
4187            Path::new("/r"),
4188            WorkflowDispatch::new("ci.yml").field("flag", "-x"),
4189        )
4190        .await
4191        .expect("workflow_dispatch");
4192        assert_eq!(
4193            rec.only_call().args_str(),
4194            ["workflow", "run", "ci.yml", "--raw-field", "flag=-x"]
4195        );
4196    }
4197
4198    // The bare `<workflow>` positional is flag-injection guarded before spawning,
4199    // like `release_view`/`api` — a leading-`-` or empty selector is refused and
4200    // nothing spawns.
4201    #[tokio::test]
4202    async fn workflow_dispatch_rejects_flag_like_workflow() {
4203        let rec = RecordingRunner::replying(Reply::ok(""));
4204        let gh = GitHub::with_runner(&rec);
4205        assert!(
4206            gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new("-evil"))
4207                .await
4208                .is_err()
4209        );
4210        assert!(
4211            gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new(""))
4212                .await
4213                .is_err()
4214        );
4215        assert!(rec.calls().is_empty(), "nothing may spawn");
4216    }
4217
4218    // Input keys name the left side of gh's `--raw-field key=value` boundary, so an
4219    // empty key or `=` would silently target a different input. Validation must run
4220    // before the runner; this ScriptedRunner has no matching command on purpose.
4221    #[tokio::test]
4222    async fn workflow_dispatch_rejects_invalid_input_keys_before_spawning() {
4223        let gh = GitHub::with_runner(ScriptedRunner::new());
4224        for key in ["", "a=b", "\0"] {
4225            let err = gh
4226                .workflow_dispatch(
4227                    Path::new("."),
4228                    WorkflowDispatch::new("ci.yml").field(key, "value"),
4229                )
4230                .await
4231                .unwrap_err();
4232            assert!(
4233                vcs_cli_support::is_invalid_input(&err),
4234                "{key:?} should be rejected before spawning, got {err:?}"
4235            );
4236        }
4237    }
4238
4239    // run_rerun pins `gh run rerun <id>` (All) and `gh run rerun <id> --failed`
4240    // (FailedOnly). The u64 id can never look like a flag, so there is no guard.
4241    #[tokio::test]
4242    async fn run_rerun_builds_argv_for_each_scope() {
4243        let rec = RecordingRunner::replying(Reply::ok(""));
4244        let gh = GitHub::with_runner(&rec);
4245        gh.run_rerun(Path::new("/r"), 42, RerunScope::All)
4246            .await
4247            .expect("rerun all");
4248        gh.run_rerun(Path::new("/r"), 42, RerunScope::FailedOnly)
4249            .await
4250            .expect("rerun failed");
4251        let calls = rec.calls();
4252        assert_eq!(calls[0].args_str(), ["run", "rerun", "42"]);
4253        assert!(!calls[0].has_flag("--failed"), "All reruns the whole run");
4254        assert_eq!(calls[1].args_str(), ["run", "rerun", "42", "--failed"]);
4255    }
4256
4257    // run_cancel pins `gh run cancel <id>`.
4258    #[tokio::test]
4259    async fn run_cancel_builds_argv() {
4260        let rec = RecordingRunner::replying(Reply::ok(""));
4261        let gh = GitHub::with_runner(&rec);
4262        gh.run_cancel(Path::new("/r"), 42).await.expect("cancel");
4263        assert_eq!(rec.only_call().args_str(), ["run", "cancel", "42"]);
4264    }
4265
4266    // A non-zero gh exit on a run-control verb surfaces as `ErrorReason::Exit`, not a
4267    // swallowed success (e.g. cancelling an already-completed run, gh exit 1).
4268    #[tokio::test]
4269    async fn run_control_surfaces_gh_exit_errors() {
4270        let gh = GitHub::with_runner(ScriptedRunner::new().on(
4271            ["gh", "run", "cancel"],
4272            Reply::fail(1, "Cannot cancel a workflow run that is completed"),
4273        ));
4274        assert!(matches!(
4275            gh.run_cancel(Path::new("."), 42)
4276                .await
4277                .unwrap_err()
4278                .reason(),
4279            ErrorReason::Exit { .. }
4280        ));
4281
4282        let gh = GitHub::with_runner(ScriptedRunner::new().on(
4283            ["gh", "workflow", "run"],
4284            Reply::fail(
4285                1,
4286                "HTTP 404: workflow x.yml not found on the default branch",
4287            ),
4288        ));
4289        assert!(matches!(
4290            gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new("x.yml"))
4291                .await
4292                .unwrap_err()
4293                .reason(),
4294            ErrorReason::Exit { .. }
4295        ));
4296    }
4297
4298    // Replays a cassette recorded against a live `gh release list`/`release
4299    // view` on this very repo (crates/github/tests/cli.rs's
4300    // `record_release_round_trip`) instead of a hand-invented JSON payload —
4301    // K-037 exists precisely because a hand-picked field set can silently
4302    // diverge from what `release view --json` actually returns.
4303    #[tokio::test]
4304    async fn release_view_requests_view_fields() {
4305        let cassette = RecordReplayRunner::replay(cassette_path("release_round_trip.json"))
4306            .expect("load recorded release cassette");
4307        let rec = RecordingRunner::new(cassette);
4308        let gh = GitHub::with_runner(&rec);
4309        let releases = gh.release_list(Path::new(".")).await.expect("release_list");
4310        let tag = releases
4311            .first()
4312            .expect("recorded cassette has a release")
4313            .tag_name
4314            .clone();
4315        let release = gh
4316            .release_view(Path::new("."), &tag)
4317            .await
4318            .expect("release_view");
4319        assert_eq!(release.tag_name, tag);
4320        assert!(
4321            release.body.as_deref().is_some_and(|b| !b.is_empty()),
4322            "release notes were recorded"
4323        );
4324        assert!(release.url.as_deref().is_some_and(|u| !u.is_empty()));
4325        let calls = rec.calls();
4326        assert_eq!(calls.len(), 2);
4327        assert_eq!(
4328            calls[1].args_str(),
4329            [
4330                "release",
4331                "view",
4332                tag.as_str(),
4333                "--json",
4334                RELEASE_VIEW_FIELDS
4335            ]
4336        );
4337    }
4338
4339    // Replays a cassette recorded against a live `gh run list`/`run view` on
4340    // this very repo (crates/github/tests/cli.rs's `record_run_round_trip`);
4341    // see the analogous `release_view_requests_view_fields` above.
4342    #[tokio::test]
4343    async fn run_list_and_view_replay_recorded_cassette() {
4344        let cassette = RecordReplayRunner::replay(cassette_path("run_round_trip.json"))
4345            .expect("load recorded run cassette");
4346        let rec = RecordingRunner::new(cassette);
4347        let gh = GitHub::with_runner(&rec);
4348        let runs = gh
4349            .run_list(Path::new("."), 3, None)
4350            .await
4351            .expect("run_list");
4352        let first = runs.first().expect("recorded cassette has runs");
4353        assert!(first.database_id > 0);
4354        assert!(!first.workflow_name.is_empty());
4355        let run = gh
4356            .run_view(Path::new("."), first.database_id)
4357            .await
4358            .expect("run_view");
4359        assert_eq!(run.database_id, first.database_id);
4360        assert_eq!(run.workflow_name, first.workflow_name);
4361        let calls = rec.calls();
4362        assert_eq!(calls.len(), 2);
4363        assert_eq!(
4364            calls[0].args_str(),
4365            ["run", "list", "--limit", "3", "--json", RUN_FIELDS]
4366        );
4367        assert_eq!(
4368            calls[1].args_str(),
4369            [
4370                "run",
4371                "view",
4372                first.database_id.to_string().as_str(),
4373                "--json",
4374                RUN_FIELDS
4375            ]
4376        );
4377    }
4378
4379    // repo_view builds the --json request and flattens gh's nested owner/branch
4380    // objects into the public RepoView.
4381    #[tokio::test]
4382    async fn repo_view_parses_scripted_json() {
4383        let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
4384        let gh =
4385            GitHub::with_runner(ScriptedRunner::new().on(["gh", "repo", "view"], Reply::ok(json)));
4386        let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
4387        assert_eq!(repo.owner, "o");
4388        assert_eq!(repo.default_branch, "main");
4389        assert!(!repo.is_private);
4390    }
4391
4392    #[cfg(feature = "mock")]
4393    #[tokio::test]
4394    async fn consumer_mocks_the_interface() {
4395        let mut mock = MockGitHubApi::new();
4396        mock.expect_auth_status().returning(|| Ok(true));
4397        assert!(mock.auth_status().await.unwrap());
4398    }
4399}
4400
4401#[cfg(test)]
4402mod label_tests {
4403    use super::*;
4404    use processkit::testing::{RecordingRunner, Reply};
4405
4406    #[tokio::test]
4407    async fn label_create_and_mutation_argv_are_exact_and_flag_values() {
4408        let rec = RecordingRunner::replying(Reply::ok("https://example.test/1\n"));
4409        let gh = GitHub::with_runner(&rec);
4410        let labels = vec!["-urgent".to_string(), "help wanted".to_string()];
4411
4412        gh.pr_create(
4413            Path::new("/repo"),
4414            PrCreate::new("T", "B").labels(labels.clone()),
4415        )
4416        .await
4417        .unwrap();
4418        gh.issue_create_with(
4419            Path::new("/repo"),
4420            IssueCreate::new("I", "D").labels(labels.clone()),
4421        )
4422        .await
4423        .unwrap();
4424        gh.at(Path::new("/repo"))
4425            .pr_add_labels(7, &labels)
4426            .await
4427            .unwrap();
4428        gh.pr_remove_labels(Path::new("/repo"), 7, &labels)
4429            .await
4430            .unwrap();
4431        gh.issue_add_labels(Path::new("/repo"), 9, &labels)
4432            .await
4433            .unwrap();
4434        gh.issue_remove_labels(Path::new("/repo"), 9, &labels)
4435            .await
4436            .unwrap();
4437
4438        let calls = rec.calls();
4439        assert_eq!(
4440            calls[0].args_str(),
4441            [
4442                "pr",
4443                "create",
4444                "--title",
4445                "T",
4446                "--body",
4447                "B",
4448                "--label",
4449                "-urgent",
4450                "--label",
4451                "help wanted"
4452            ]
4453        );
4454        assert_eq!(
4455            calls[1].args_str(),
4456            [
4457                "issue",
4458                "create",
4459                "--title",
4460                "I",
4461                "--body",
4462                "D",
4463                "--label",
4464                "-urgent",
4465                "--label",
4466                "help wanted"
4467            ]
4468        );
4469        assert_eq!(
4470            calls[2].args_str(),
4471            [
4472                "pr",
4473                "edit",
4474                "7",
4475                "--add-label",
4476                "-urgent",
4477                "--add-label",
4478                "help wanted"
4479            ]
4480        );
4481        assert_eq!(calls[2].cwd.as_deref(), Some(Path::new("/repo")));
4482        assert_eq!(
4483            calls[3].args_str(),
4484            [
4485                "pr",
4486                "edit",
4487                "7",
4488                "--remove-label",
4489                "-urgent",
4490                "--remove-label",
4491                "help wanted"
4492            ]
4493        );
4494        assert_eq!(
4495            calls[4].args_str(),
4496            [
4497                "issue",
4498                "edit",
4499                "9",
4500                "--add-label",
4501                "-urgent",
4502                "--add-label",
4503                "help wanted"
4504            ]
4505        );
4506        assert_eq!(
4507            calls[5].args_str(),
4508            [
4509                "issue",
4510                "edit",
4511                "9",
4512                "--remove-label",
4513                "-urgent",
4514                "--remove-label",
4515                "help wanted"
4516            ]
4517        );
4518    }
4519
4520    #[tokio::test]
4521    async fn empty_label_mutation_is_rejected_before_spawn() {
4522        let rec = RecordingRunner::replying(Reply::ok(""));
4523        let err = GitHub::with_runner(&rec)
4524            .pr_add_labels(Path::new("/repo"), 1, &[])
4525            .await
4526            .unwrap_err();
4527        assert!(vcs_cli_support::is_invalid_input(&err));
4528        assert!(rec.calls().is_empty());
4529    }
4530}
4531
4532// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
4533#[doc = include_str!("../docs/github.md")]
4534#[allow(rustdoc::broken_intra_doc_links)]
4535pub mod guide {}