Skip to main content

vcs_github/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-github` — automate GitHub from Rust by driving the `gh` CLI.
4//!
5//! You call typed `async` methods; `vcs-github` runs the real `gh`, parses its
6//! output, and hands you structured values — so you get *gh's own* behaviour, auth,
7//! and host resolution, not a reimplementation of the GitHub REST/GraphQL API.
8//! Async, structured errors, mockable. Every command runs inside an OS **job** (an
9//! OS-level container that kills the whole process tree if your program exits, via
10//! [`processkit`]) so a `gh` subprocess is never orphaned, with an optional
11//! per-client [timeout](GitHub::default_timeout). Read-style methods ask `gh` for
12//! `--json` and deserialize it; nothing scrapes human-readable output.
13//!
14//! # What you can do
15//!
16//! Check auth · view the repo · the full pull-request lifecycle (list / view /
17//! create / merge / mark-ready / close, review / comment, CI checks, feedback) ·
18//! issues · releases · GitHub Actions runs (list / view / watch). One tiny call to
19//! start:
20//!
21//! ```no_run
22//! use std::path::Path;
23//! use vcs_github::{GitHub, GitHubApi};
24//! # async fn demo() -> Result<(), processkit::Error> {
25//! let gh = GitHub::new();
26//! let prs = gh.pr_list(Path::new(".")).await?; // up to 100 open PRs
27//! # let _ = prs; Ok(()) }
28//! ```
29//!
30//! # The surface (engineering reference)
31//!
32//! - **[`GitHubApi`]** — the object-safe trait every operation lives on. Depend
33//!   on `&dyn GitHubApi` (or generically on `impl GitHubApi`) so a test can swap
34//!   the real client for a double. Repo-scoped methods take the working
35//!   directory as the first argument and return typed results ([`PullRequest`],
36//!   [`Issue`], [`Repo`], [`CheckRun`], [`WorkflowRun`], [`Release`],
37//!   [`PrFeedback`], …) or a structured [`Error`].
38//! - **[`GitHub`]** — the real client. [`GitHub::new`] uses the job-backed
39//!   runner; [`GitHub::with_runner`] injects a fake one for tests. It is generic
40//!   over the [`ProcessRunner`] seam, defaulting to the production runner.
41//!   [`with_credentials`](GitHub::with_credentials) attaches a
42//!   [`CredentialProvider`] to supply a token per operation (injected as
43//!   `GH_TOKEN`, never in `argv`) — opt-in, off by default (ambient `gh` auth).
44//! - **[`GitHubAt`]** — a cwd-bound view ([`GitHub::at`]) whose methods drop the
45//!   leading `dir`, so `gh.at(dir).pr_list()` reads as `gh.pr_list(dir)` — handy
46//!   when one client drives one checkout.
47//! - **Method groups** on the trait: PRs ([`pr_list`](GitHubApi::pr_list),
48//!   [`pr_view`](GitHubApi::pr_view), [`pr_create`](GitHubApi::pr_create),
49//!   [`pr_merge`](GitHubApi::pr_merge), [`pr_ready`](GitHubApi::pr_ready),
50//!   [`pr_close`](GitHubApi::pr_close), [`pr_review`](GitHubApi::pr_review),
51//!   [`pr_comment`](GitHubApi::pr_comment), [`pr_edit`](GitHubApi::pr_edit), [`pr_checks`](GitHubApi::pr_checks),
52//!   [`pr_feedback`](GitHubApi::pr_feedback), …); Actions runs
53//!   ([`run_list`](GitHubApi::run_list), [`run_view`](GitHubApi::run_view),
54//!   [`run_watch`](GitHubApi::run_watch) — *blocking*, bounded by the client
55//!   timeout); issues & releases ([`issue_create`](GitHubApi::issue_create),
56//!   [`release_view`](GitHubApi::release_view), …); plus the escape hatches
57//!   [`run`](GitHubApi::run) / [`api`](GitHubApi::api) for anything unmodelled.
58//! - **Builder specs** for the multi-option commands — [`PrCreate`] (title/body
59//!   with optional `head`/`base`), [`PrEdit`] (optional `title` and/or `body`
60//!   for `pr edit`), [`PrMerge`] (strategy [`MergeStrategy`],
61//!   `--auto`, `--delete-branch`), and [`ReviewAction`] (whose private fields make
62//!   an empty-body request-changes unrepresentable) — each `#[non_exhaustive]`,
63//!   built with a constructor and chained setters, named after the flags they emit.
64//!
65//! # Recipes
66//!
67//! Read state — depend on the trait so the same code takes a real client or a mock:
68//!
69//! ```no_run
70//! use std::path::Path;
71//! use vcs_github::{GitHub, GitHubApi};
72//! # async fn demo() -> Result<(), processkit::Error> {
73//! let gh = GitHub::new();
74//! let dir = Path::new(".");
75//! let authed = gh.auth_status().await?;          // is `gh` logged in?
76//! let open = gh.pr_list(dir).await?;             // up to 100 open PRs
77//! # let _ = (authed, open); Ok(()) }
78//! ```
79//!
80//! Mutate through the builder specs — open a PR, approve it, then squash-merge:
81//!
82//! ```no_run
83//! use std::path::Path;
84//! use vcs_github::{GitHub, GitHubApi, PrCreate, PrMerge, ReviewAction};
85//! # async fn demo(gh: &GitHub) -> Result<(), processkit::Error> {
86//! let dir = Path::new(".");
87//! let url = gh.pr_create(dir, PrCreate::new("Add X", "…").base("main")).await?;
88//! gh.pr_review(dir, 7, ReviewAction::approve().with_body("LGTM")).await?;
89//! gh.pr_merge(dir, 7, PrMerge::squash().delete_branch()).await?;
90//! # let _ = url; Ok(()) }
91//! ```
92//!
93//! # Testing
94//!
95//! Two seams: enable the **`mock`** feature for a `mockall`-generated
96//! `MockGitHubApi` (stub whole methods), or inject a
97//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`GitHub::with_runner`]
98//! to exercise the *real* argv-building and parsing against canned output — no
99//! `gh` binary or network needed, so it runs on CI. The cross-cutting testing
100//! patterns live in
101//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
102//!
103//! # Safety
104//!
105//! Caller values placed in a bare positional argv slot (an `api` endpoint, a
106//! release `tag`) are refused before spawning if empty or starting with `-` —
107//! `gh` would parse them as flags. Flag-value slots (`--body <b>`,
108//! `--branch <b>`) are consumed verbatim and need no guard.
109//!
110//! # In-depth guide
111//!
112//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
113//! from `docs/`. See the [`guide`] module.
114
115use std::path::Path;
116use std::sync::Arc;
117
118use processkit::ProcessRunner;
119// The shared managed client (lock-retry + credential injection) and the
120// credential seam — re-exported so a consumer can supply a token provider.
121use vcs_cli_support::ManagedClient;
122pub use vcs_cli_support::{
123    Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
124    Secret, StaticCredential, provider_fn,
125};
126// Re-export the processkit types in this crate's public API (also brings
127// `Error`/`Result`/`ProcessResult` into scope here).
128pub use processkit::{Error, ProcessResult, Result};
129// Re-exported so a consumer can name the token for `default_cancel_on` without
130// taking a direct `processkit` dependency. (Cancellation is core in processkit
131// 0.10 — always available, no feature.)
132pub use processkit::CancellationToken;
133
134mod parse;
135pub use parse::{
136    CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, Repo, Review,
137    WorkflowRun,
138};
139
140/// Name of the underlying CLI binary this crate drives.
141pub const BINARY: &str = "gh";
142
143const PR_FIELDS: &str = "number,title,state,headRefName,baseRefName,url";
144const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
145const ISSUE_LIST_FIELDS: &str = "number,title,state,body,url";
146const ISSUE_VIEW_FIELDS: &str = "number,title,state,body,url";
147const RUN_FIELDS: &str =
148    "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
149const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
150const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
151const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease";
152
153/// Injection guard for bare positional argv slots: a caller-supplied value
154/// with a leading `-` is parsed by gh's CLI as a *flag* (verified: `gh api -evil` →
155/// flag parsing), and an empty value changes a command's
156/// meaning. Refuse both before anything spawns. Flag-VALUE positions
157/// (`--body <b>`, `--branch <b>`) need no guard — gh consumes the next token
158/// verbatim there (verified).
159fn reject_flag_like(what: &str, value: &str) -> Result<()> {
160    vcs_cli_support::reject_flag_like(BINARY, what, value)
161}
162
163/// How [`GitHubApi::pr_merge`] merges the PR — exactly one of gh's mutually
164/// exclusive strategy flags.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum MergeStrategy {
168    /// A merge commit (`--merge`).
169    Merge,
170    /// Squash into one commit (`--squash`).
171    Squash,
172    /// Rebase the commits onto the base (`--rebase`).
173    Rebase,
174}
175
176impl MergeStrategy {
177    fn flag(self) -> &'static str {
178        match self {
179            MergeStrategy::Merge => "--merge",
180            MergeStrategy::Squash => "--squash",
181            MergeStrategy::Rebase => "--rebase",
182        }
183    }
184}
185
186/// Options for [`GitHubApi::pr_merge`] (`gh pr merge`).
187///
188/// `#[non_exhaustive]`, so build it through the strategy constructors —
189/// [`merge`](PrMerge::merge) / [`squash`](PrMerge::squash) /
190/// [`rebase`](PrMerge::rebase), then [`auto`](PrMerge::auto) /
191/// [`delete_branch`](PrMerge::delete_branch) — rather than a struct literal.
192#[derive(Debug, Clone)]
193#[non_exhaustive]
194pub struct PrMerge {
195    /// The merge strategy (exactly one of gh's `--merge`/`--squash`/`--rebase`).
196    pub strategy: MergeStrategy,
197    /// Enable auto-merge: merge once requirements are met (`--auto`).
198    pub auto: bool,
199    /// Delete the head branch after the merge (`--delete-branch`).
200    pub delete_branch: bool,
201}
202
203impl PrMerge {
204    /// Merge with a merge commit (`gh pr merge --merge`).
205    pub fn merge() -> Self {
206        Self::with(MergeStrategy::Merge)
207    }
208
209    /// Squash-merge (`gh pr merge --squash`).
210    pub fn squash() -> Self {
211        Self::with(MergeStrategy::Squash)
212    }
213
214    /// Rebase-merge (`gh pr merge --rebase`).
215    pub fn rebase() -> Self {
216        Self::with(MergeStrategy::Rebase)
217    }
218
219    fn with(strategy: MergeStrategy) -> Self {
220        Self {
221            strategy,
222            auto: false,
223            delete_branch: false,
224        }
225    }
226
227    /// Merge automatically once requirements are met (`--auto`).
228    pub fn auto(mut self) -> Self {
229        self.auto = true;
230        self
231    }
232
233    /// Delete the head branch after merging (`--delete-branch`).
234    pub fn delete_branch(mut self) -> Self {
235        self.delete_branch = true;
236        self
237    }
238}
239
240/// Options for [`GitHubApi::pr_create`] (`gh pr create`).
241///
242/// `#[non_exhaustive]`, so build it through [`PrCreate::new`] (title + body)
243/// and the chained [`head`](PrCreate::head) / [`base`](PrCreate::base) setters
244/// rather than a struct literal.
245#[derive(Debug, Clone)]
246#[non_exhaustive]
247pub struct PrCreate {
248    /// The PR title (`--title`).
249    pub title: String,
250    /// The PR body (`--body`).
251    pub body: String,
252    /// The source branch (`--head`); `None` = the current branch.
253    pub head: Option<String>,
254    /// The target branch (`--base`); `None` = the repo default.
255    pub base: Option<String>,
256}
257
258impl PrCreate {
259    /// A PR with the given title and body, opened from the current branch into
260    /// the repo default (`gh pr create --title <title> --body <body>`).
261    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
262        Self {
263            title: title.into(),
264            body: body.into(),
265            head: None,
266            base: None,
267        }
268    }
269
270    /// Set the source branch (`--head`).
271    pub fn head(mut self, head: impl Into<String>) -> Self {
272        self.head = Some(head.into());
273        self
274    }
275
276    /// Set the target branch (`--base`).
277    pub fn base(mut self, base: impl Into<String>) -> Self {
278        self.base = Some(base.into());
279        self
280    }
281}
282
283/// Options for [`GitHubApi::pr_edit`] (`gh pr edit`).
284///
285/// `#[non_exhaustive]`, so build it through [`PrEdit::new`] and the chained
286/// [`title`](PrEdit::title) / [`body`](PrEdit::body) setters rather than a
287/// struct literal. At least one of `title` or `body` must be `Some`; both
288/// `None` is rejected by the facade before spawning (an explicit error, not a
289/// silent no-op). An empty string is a real value — gh clears the field on
290/// `--title ""` / `--body ""` — not a `None`.
291#[derive(Debug, Clone, PartialEq, Eq)]
292#[non_exhaustive]
293pub struct PrEdit {
294    /// The new title (`--title`); `None` leaves the title alone.
295    pub title: Option<String>,
296    /// The new body (`--body`); `None` leaves the body alone.
297    pub body: Option<String>,
298}
299
300impl PrEdit {
301    /// An edit that leaves both fields alone (the facade rejects both-`None`
302    /// before reaching the wrapper). Start with this and add what you want to
303    /// change via [`title`](PrEdit::title) / [`body`](PrEdit::body).
304    pub fn new() -> Self {
305        Self {
306            title: None,
307            body: None,
308        }
309    }
310
311    /// Set the new title (`--title`).
312    pub fn title(mut self, title: impl Into<String>) -> Self {
313        self.title = Some(title.into());
314        self
315    }
316
317    /// Set the new body (`--body`).
318    pub fn body(mut self, body: impl Into<String>) -> Self {
319        self.body = Some(body.into());
320        self
321    }
322}
323
324impl Default for PrEdit {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330/// Which kind of review [`GitHubApi::pr_review`] submits — match on
331/// [`ReviewAction::kind`] to read it back.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333#[non_exhaustive]
334pub enum ReviewKind {
335    /// Approve (`--approve`).
336    Approve,
337    /// Request changes (`--request-changes`).
338    RequestChanges,
339    /// A comment-only review (`--comment`).
340    Comment,
341}
342
343/// What [`GitHubApi::pr_review`] submits (`gh pr review`).
344///
345/// The fields are **private** so the invariant holds by construction: gh
346/// *requires* a body for request-changes/comment reviews, so those are only
347/// reachable through [`request_changes`](ReviewAction::request_changes) /
348/// [`comment`](ReviewAction::comment), which both take the body — an empty-body
349/// request-changes is unrepresentable. Approve's body is optional
350/// ([`approve`](ReviewAction::approve) starts with none; attach one with
351/// [`with_body`](ReviewAction::with_body)). Read the parts back via
352/// [`kind`](ReviewAction::kind) / [`body`](ReviewAction::body).
353#[derive(Debug, Clone, PartialEq, Eq)]
354#[non_exhaustive]
355pub struct ReviewAction {
356    kind: ReviewKind,
357    body: Option<String>,
358}
359
360impl ReviewAction {
361    /// Approve, with no body (`--approve`). Attach one with
362    /// [`with_body`](ReviewAction::with_body).
363    pub fn approve() -> Self {
364        Self {
365            kind: ReviewKind::Approve,
366            body: None,
367        }
368    }
369
370    /// Request changes; gh requires the body
371    /// (`--request-changes --body <body>`).
372    pub fn request_changes(body: impl Into<String>) -> Self {
373        Self {
374            kind: ReviewKind::RequestChanges,
375            body: Some(body.into()),
376        }
377    }
378
379    /// A comment-only review; gh requires the body (`--comment --body <body>`).
380    pub fn comment(body: impl Into<String>) -> Self {
381        Self {
382            kind: ReviewKind::Comment,
383            body: Some(body.into()),
384        }
385    }
386
387    /// Attach or replace the body — mainly to give an [`approve`](ReviewAction::approve)
388    /// a message.
389    pub fn with_body(mut self, body: impl Into<String>) -> Self {
390        self.body = Some(body.into());
391        self
392    }
393
394    /// Which kind of review this is.
395    pub fn kind(&self) -> ReviewKind {
396        self.kind
397    }
398
399    /// The review body, if any.
400    pub fn body(&self) -> Option<&str> {
401        self.body.as_deref()
402    }
403}
404
405/// The GitHub operations this crate exposes — the interface consumers code
406/// against and mock in tests.
407#[cfg_attr(feature = "mock", mockall::automock)]
408#[async_trait::async_trait]
409pub trait GitHubApi: Send + Sync {
410    /// Run `gh <args>`, returning trimmed stdout (throws on a non-zero exit).
411    async fn run(&self, args: &[String]) -> Result<String>;
412    /// Like [`GitHubApi::run`] but never errors on a non-zero exit — returns the
413    /// captured [`ProcessResult`].
414    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
415    /// Installed GitHub CLI version (`gh --version`).
416    async fn version(&self) -> Result<String>;
417    /// Whether the user is authenticated (`gh auth status` exits zero). Reflects
418    /// the exit code as a bool — any non-zero exit reads as `false`, never an
419    /// error; only a spawn failure or timeout errors.
420    async fn auth_status(&self) -> Result<bool>;
421    /// The repository for `dir` (`gh repo view --json …`).
422    async fn repo_view(&self, dir: &Path) -> Result<Repo>;
423    /// Pull requests for `dir` (`gh pr list --limit 100 --json …`). Returns up to
424    /// 100 open PRs; use [`run`](GitHubApi::run) for more.
425    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
426    /// Pull requests that merge `head` into `base`, in any state — open, closed,
427    /// or merged (`gh pr list --head <head> --base <base> --state all --limit 100
428    /// --json …`). Each carries its title, URL, and `state`. Empty when none
429    /// match; returns up to 100 (use [`run`](GitHubApi::run) for more).
430    async fn pr_list_for_branch(
431        &self,
432        dir: &Path,
433        head: &str,
434        base: &str,
435    ) -> Result<Vec<PullRequest>>;
436    /// A single pull request by number (`gh pr view <n> --json …`).
437    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
438    /// Issues for `dir` (`gh issue list --limit 100 --json …`). Returns up to 100
439    /// open issues; use [`run`](GitHubApi::run) for more.
440    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
441    /// Open a pull request, returning its URL (`gh pr create`) — see
442    /// [`PrCreate`] for the title/body and the optional `head` (source branch;
443    /// `None` = current branch) / `base` (target; `None` = repo default).
444    async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
445    /// Raw GitHub REST/GraphQL response body (`gh api <endpoint>`).
446    async fn api(&self, endpoint: &str) -> Result<String>;
447
448    // --- PR lifecycle ----------------------------------------------------
449
450    /// Merge a pull request (`gh pr merge <n> --merge|--squash|--rebase
451    /// [--auto] [--delete-branch]`) — see [`PrMerge`].
452    async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
453    /// Mark a draft pull request as ready for review (`gh pr ready <n>`).
454    async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()>;
455    /// Close a pull request without merging (`gh pr close <n>
456    /// [--delete-branch]`).
457    async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()>;
458    /// The PR's checks (`gh pr checks <n> --json …`). gh signals the overall
459    /// outcome through its exit code — 0 all passed, 8 still pending, 1 some
460    /// failed — and emits the same JSON either way, so all three return the
461    /// parsed list; branch on each entry's [`bucket`](CheckRun::bucket). A PR
462    /// with no checks at all yields an empty list (gh's "no checks reported"
463    /// exit). Any other exit (no such PR, auth required, …) errors.
464    async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
465    /// Submit a review (`gh pr review <n> --approve|--request-changes|--comment
466    /// [--body <body>]`) — see [`ReviewAction`] (request-changes/comment carry a
467    /// required body by construction).
468    async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
469    /// Add a conversation comment, returning its URL
470    /// (`gh pr comment <n> --body <body>`).
471    async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
472    /// Edit a pull request's title and/or body
473    /// (`gh pr edit <n> [--title <title>] [--body <body>]`). At least one of
474    /// `title` or `body` must be `Some` — the facade rejects both-`None`
475    /// before reaching the wrapper, so the default implementation is
476    /// unreachable in normal use. **Defaulted** to `Error::Unsupported` so
477    /// external implementers of the trait keep compiling when the crate
478    /// bumps.
479    #[allow(unused_variables)]
480    async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
481        Err(Error::Unsupported {
482            operation: "pr_edit".into(),
483        })
484    }
485    /// The PR's submitted reviews and conversation comments
486    /// (`gh pr view <n> --json reviews,comments`).
487    async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
488
489    // --- Actions runs ------------------------------------------------------
490
491    /// Recent workflow runs, newest first (`gh run list --limit <n>
492    /// [--branch <b>] --json …`). `branch` is an owned `Option<String>` to keep
493    /// the trait `mockall`-friendly.
494    async fn run_list(
495        &self,
496        dir: &Path,
497        limit: u64,
498        branch: Option<String>,
499    ) -> Result<Vec<WorkflowRun>>;
500    /// A single workflow run by id (`gh run view <id> --json …`); the id is
501    /// [`WorkflowRun::database_id`].
502    async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
503    /// Block until the run finishes, then return its final state
504    /// (`gh run watch <id>`, then a `run view`). Inspect
505    /// [`conclusion`](WorkflowRun::conclusion) for the outcome — exit codes
506    /// can't distinguish a failed run from a cancelled one.
507    ///
508    /// **Blocks for the whole run.** A client
509    /// [`default_timeout`](GitHub::default_timeout) kills the watch when it
510    /// elapses (`Error::Timeout`) — drive this from a client with no (or a
511    /// generous) timeout.
512    async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
513
514    // --- Issues / releases ---------------------------------------------------
515
516    /// Open an issue, returning its URL
517    /// (`gh issue create --title <title> --body <body>`).
518    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
519    /// A single issue by number, with `body`/`url` filled
520    /// (`gh issue view <n> --json …`).
521    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
522    /// Releases, newest first (`gh release list --limit 100 --json …`); `body`/`url`
523    /// are not fetched here — use [`release_view`](GitHubApi::release_view).
524    /// Returns up to 100 releases; use [`run`](GitHubApi::run) for more.
525    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
526    /// A single release by tag, with `body`/`url` filled
527    /// (`gh release view <tag> --json …`). gh reports `is_latest` only from
528    /// [`release_list`](GitHubApi::release_list); here it defaults to `false`.
529    async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
530}
531
532/// The real GitHub client. Generic over the [`ProcessRunner`] so tests can inject
533/// a fake process executor; [`GitHub::new`] uses the real job-backed runner.
534///
535/// Wraps a [`ManagedClient`]. By default it authenticates through `gh`'s own
536/// ambient login; attach a [`CredentialProvider`] with
537/// [`with_credentials`](GitHub::with_credentials) to supply a token per operation
538/// — it is injected as `GH_TOKEN` on every `gh` invocation.
539pub struct GitHub<R: ProcessRunner = processkit::JobRunner> {
540    core: ManagedClient<R>,
541}
542
543impl GitHub<processkit::JobRunner> {
544    /// Create a client driving the real job-backed runner.
545    pub fn new() -> Self {
546        Self {
547            core: ManagedClient::new(BINARY).with_token_env(CredentialService::GitHub, "GH_TOKEN"),
548        }
549    }
550}
551
552impl Default for GitHub<processkit::JobRunner> {
553    fn default() -> Self {
554        Self::new()
555    }
556}
557
558impl<R: ProcessRunner> GitHub<R> {
559    /// Create a client driving `runner` — inject a fake in tests.
560    pub fn with_runner(runner: R) -> Self {
561        Self {
562            core: ManagedClient::with_runner(BINARY, runner)
563                .with_token_env(CredentialService::GitHub, "GH_TOKEN"),
564        }
565    }
566
567    /// Apply a default timeout to every command this client builds.
568    pub fn default_timeout(mut self, timeout: std::time::Duration) -> Self {
569        self.core = self.core.default_timeout(timeout);
570        self
571    }
572
573    /// Set an environment variable on every command this client builds.
574    pub fn default_env(
575        mut self,
576        key: impl AsRef<std::ffi::OsStr>,
577        value: impl AsRef<std::ffi::OsStr>,
578    ) -> Self {
579        self.core = self.core.default_env(key, value);
580        self
581    }
582
583    /// Remove an inherited environment variable on every command this client builds.
584    pub fn default_env_remove(mut self, key: impl AsRef<std::ffi::OsStr>) -> Self {
585        self.core = self.core.default_env_remove(key);
586        self
587    }
588
589    /// Cancel every command this client builds when `token` fires.
590    pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
591        self.core = self.core.default_cancel_on(token);
592        self
593    }
594
595    /// Supply credentials per operation via a [`CredentialProvider`] — opt-in, off
596    /// by default (ambient `gh` auth). The resolved token is injected as `GH_TOKEN`
597    /// on every `gh` invocation, overriding the ambient login for this client.
598    #[must_use]
599    pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
600        self.core = self.core.with_credentials(provider);
601        self
602    }
603
604    /// Convenience for the common case: authenticate with a single static `token`,
605    /// injected as `GH_TOKEN`. Shorthand for
606    /// `with_credentials(Arc::new(StaticCredential::token(token)))`.
607    #[must_use]
608    pub fn with_token(self, token: impl Into<Secret>) -> Self {
609        self.with_credentials(Arc::new(StaticCredential::token(token)))
610    }
611
612    /// Convenience: read the token from environment variable `var` at request time
613    /// (injected as `GH_TOKEN`); if `var` is unset/empty, fall back to ambient auth.
614    /// Shorthand for `with_credentials(Arc::new(EnvToken::new(var)))`.
615    #[must_use]
616    pub fn with_env_token(self, var: impl Into<String>) -> Self {
617        self.with_credentials(Arc::new(EnvToken::new(var)))
618    }
619}
620
621#[async_trait::async_trait]
622impl<R: ProcessRunner> GitHubApi for GitHub<R> {
623    async fn run(&self, args: &[String]) -> Result<String> {
624        self.core.run(args).await
625    }
626
627    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
628        self.core.output(args).await
629    }
630
631    async fn version(&self) -> Result<String> {
632        self.core.run(["--version"]).await
633    }
634
635    async fn auth_status(&self) -> Result<bool> {
636        // `gh auth status` exits 0 when authenticated, non-zero when not — an
637        // exit-code answer. `exit_code` reads the exit code without erroring on a
638        // non-zero one (a spawn failure or timeout still errors), so ANY non-zero
639        // exit — not just the documented 1 — maps to "not authenticated" rather
640        // than surfacing as an error. `probe` would reject an unusual exit code.
641        Ok(self.core.exit_code(["auth", "status"]).await? == 0)
642    }
643
644    async fn repo_view(&self, dir: &Path) -> Result<Repo> {
645        self.core
646            .try_parse(
647                self.core
648                    .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
649                parse::parse_repo,
650            )
651            .await
652    }
653
654    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
655        self.core
656            .try_parse(
657                self.core
658                    .command_in(dir, ["pr", "list", "--limit", "100", "--json", PR_FIELDS]),
659                parse::from_json,
660            )
661            .await
662    }
663
664    async fn pr_list_for_branch(
665        &self,
666        dir: &Path,
667        head: &str,
668        base: &str,
669    ) -> Result<Vec<PullRequest>> {
670        // `--state all` so a closed/merged PR for this branch pair is reported
671        // too, not just open ones (gh's default); the caller filters on `state`.
672        self.core
673            .try_parse(
674                self.core.command_in(
675                    dir,
676                    [
677                        "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
678                        "100", "--json", PR_FIELDS,
679                    ],
680                ),
681                parse::from_json,
682            )
683            .await
684    }
685
686    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
687        let n = number.to_string();
688        self.core
689            .try_parse(
690                self.core
691                    .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
692                parse::from_json,
693            )
694            .await
695    }
696
697    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
698        self.core
699            .try_parse(
700                self.core.command_in(
701                    dir,
702                    [
703                        "issue",
704                        "list",
705                        "--limit",
706                        "100",
707                        "--json",
708                        ISSUE_LIST_FIELDS,
709                    ],
710                ),
711                parse::from_json,
712            )
713            .await
714    }
715
716    async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
717        let mut args = vec![
718            "pr",
719            "create",
720            "--title",
721            spec.title.as_str(),
722            "--body",
723            spec.body.as_str(),
724        ];
725        if let Some(head) = spec.head.as_deref() {
726            args.push("--head");
727            args.push(head);
728        }
729        if let Some(base) = spec.base.as_deref() {
730            args.push("--base");
731            args.push(base);
732        }
733        self.core.run(self.core.command_in(dir, args)).await
734    }
735
736    async fn api(&self, endpoint: &str) -> Result<String> {
737        reject_flag_like("endpoint", endpoint)?;
738        self.core.run(["api", endpoint]).await
739    }
740
741    async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
742        let n = number.to_string();
743        let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
744        if merge.auto {
745            args.push("--auto");
746        }
747        if merge.delete_branch {
748            args.push("--delete-branch");
749        }
750        self.core.run_unit(self.core.command_in(dir, args)).await
751    }
752
753    async fn pr_ready(&self, dir: &Path, number: u64) -> Result<()> {
754        let n = number.to_string();
755        self.core
756            .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
757            .await
758    }
759
760    async fn pr_close(&self, dir: &Path, number: u64, delete_branch: bool) -> Result<()> {
761        let n = number.to_string();
762        let mut args = vec!["pr", "close", n.as_str()];
763        if delete_branch {
764            args.push("--delete-branch");
765        }
766        self.core.run_unit(self.core.command_in(dir, args)).await
767    }
768
769    async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
770        let n = number.to_string();
771        let res = self
772            .core
773            .output(
774                self.core
775                    .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
776            )
777            .await?;
778        match res.code() {
779            // gh's exit code carries the *overall* outcome (0 = all pass,
780            // 8 = pending, 1 = some failed) but prints the same JSON for all
781            // three — parse it and let the caller branch on each `bucket`.
782            // A parse failure here is a real schema problem and must surface
783            // as `Error::Parse`, not be masked by the exit code.
784            Some(0) => parse::from_json(res.stdout()),
785            Some(1 | 8) if !res.stdout().trim().is_empty() => parse::from_json(res.stdout()),
786            // gh exits 1 with NO JSON for a PR that simply has no checks — the
787            // one bare non-zero we read as an empty list (cf. jj's
788            // `resolve_list` and its "No conflicts" exit). Matched
789            // case-insensitively so a capitalization tweak in gh's wording
790            // ("no checks reported on the 'X' branch") doesn't turn the empty case
791            // into a hard error.
792            _ if res
793                .stderr()
794                .to_ascii_lowercase()
795                .contains("no checks reported") =>
796            {
797                Ok(Vec::new())
798            }
799            // Anything else (no such PR, auth required, timeout, signal…) is a
800            // genuine failure; `ensure_success` builds the faithful error.
801            _ => {
802                res.ensure_success()?;
803                Ok(Vec::new()) // unreachable: a non-zero exit always errors above.
804            }
805        }
806    }
807
808    async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
809        let n = number.to_string();
810        let mut args = vec!["pr", "review", n.as_str()];
811        args.push(match action.kind() {
812            ReviewKind::Approve => "--approve",
813            ReviewKind::RequestChanges => "--request-changes",
814            ReviewKind::Comment => "--comment",
815        });
816        if let Some(body) = action.body() {
817            args.push("--body");
818            args.push(body);
819        }
820        self.core.run_unit(self.core.command_in(dir, args)).await
821    }
822
823    async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
824        // `--body` is mandatory here: without it gh falls back to an
825        // interactive prompt, which would hang a headless run.
826        let n = number.to_string();
827        self.core
828            .run(
829                self.core
830                    .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
831            )
832            .await
833    }
834
835    async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
836        // `--title` and `--body` are flag-VALUE positions: gh consumes the
837        // next token verbatim, so the leading-`-` check is not needed here.
838        // The facade rejects both-`None` before reaching this; an empty string
839        // is intentional (clears the field). We still skip absent fields so
840        // the argv doesn't carry a stray `--title` with no value.
841        let n = number.to_string();
842        let mut args = vec!["pr", "edit", n.as_str()];
843        if let Some(title) = edit.title.as_deref() {
844            args.push("--title");
845            args.push(title);
846        }
847        if let Some(body) = edit.body.as_deref() {
848            args.push("--body");
849            args.push(body);
850        }
851        self.core.run_unit(self.core.command_in(dir, args)).await
852    }
853
854    async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
855        let n = number.to_string();
856        self.core
857            .try_parse(
858                self.core.command_in(
859                    dir,
860                    ["pr", "view", n.as_str(), "--json", "reviews,comments"],
861                ),
862                parse::parse_feedback,
863            )
864            .await
865    }
866
867    async fn run_list(
868        &self,
869        dir: &Path,
870        limit: u64,
871        branch: Option<String>,
872    ) -> Result<Vec<WorkflowRun>> {
873        let limit = limit.to_string();
874        let mut args = vec!["run", "list", "--limit", limit.as_str()];
875        if let Some(branch) = branch.as_deref() {
876            args.push("--branch");
877            args.push(branch);
878        }
879        args.extend(["--json", RUN_FIELDS]);
880        self.core
881            .try_parse(self.core.command_in(dir, args), parse::from_json)
882            .await
883    }
884
885    async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
886        let id = id.to_string();
887        self.core
888            .try_parse(
889                self.core
890                    .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
891                parse::from_json,
892            )
893            .await
894    }
895
896    async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
897        // Block until the run completes. `--exit-status` is deliberately NOT
898        // passed: it would map the run's outcome onto the exit code (1 failed,
899        // 2 cancelled), which can't be reported faithfully — the follow-up
900        // `run view`'s `conclusion` can. Without it, a non-zero watch exit is a
901        // genuine error (no such run, auth, …). `output` does NOT error on a
902        // timeout (it returns the result with a timeout flag), so
903        // `ensure_success` is what surfaces a killed watch as `Error::Timeout`
904        // instead of reading a half-finished run below.
905        let id_str = id.to_string();
906        self.core
907            .output(self.core.command_in(dir, ["run", "watch", id_str.as_str()]))
908            .await?
909            .ensure_success()?;
910        self.run_view(dir, id).await
911    }
912
913    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
914        self.core
915            .run(
916                self.core
917                    .command_in(dir, ["issue", "create", "--title", title, "--body", body]),
918            )
919            .await
920    }
921
922    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
923        let n = number.to_string();
924        self.core
925            .try_parse(
926                self.core.command_in(
927                    dir,
928                    ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
929                ),
930                parse::from_json,
931            )
932            .await
933    }
934
935    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
936        self.core
937            .try_parse(
938                self.core.command_in(
939                    dir,
940                    [
941                        "release",
942                        "list",
943                        "--limit",
944                        "100",
945                        "--json",
946                        RELEASE_LIST_FIELDS,
947                    ],
948                ),
949                parse::from_json,
950            )
951            .await
952    }
953
954    async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
955        reject_flag_like("tag", tag)?;
956        self.core
957            .try_parse(
958                self.core
959                    .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
960                parse::from_json,
961            )
962            .await
963    }
964}
965
966impl<R: ProcessRunner> GitHub<R> {
967    /// Run `gh <args>` over string slices — `gh.run_args(&["pr", "list"])`
968    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
969    /// trait), so it can take `&[&str]`; forwards to the same path as
970    /// [`GitHubApi::run`].
971    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
972        self.core.run(args).await
973    }
974
975    /// Like [`run_args`](GitHub::run_args) but never errors on a non-zero exit
976    /// (mirrors [`GitHubApi::run_raw`]).
977    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
978        self.core.output(args).await
979    }
980
981    /// Bind this client to `dir`, returning a [`GitHubAt`] handle whose `dir`-taking
982    /// methods omit that argument: `gh.at(dir).pr_list()` runs
983    /// [`pr_list`](GitHubApi::pr_list) against `dir`.
984    pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
985        GitHubAt { gh: self, dir }
986    }
987}
988
989/// A [`GitHub`] client with a working directory bound, so its repo-scoped methods
990/// drop the leading `dir` argument (`gh.at(dir).pr_list()`). Construct one with
991/// [`GitHub::at`].
992pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
993    gh: &'a GitHub<R>,
994    dir: &'a Path,
995}
996
997// Hand-written rather than derived: holding only references, the view is `Copy`
998// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
999// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the handle.
1000impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
1001    fn clone(&self) -> Self {
1002        *self
1003    }
1004}
1005impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
1006
1007/// Generate [`GitHubAt`] forwarders: `bare` methods forward verbatim, `dir`
1008/// methods inject `self.dir` as the first argument.
1009macro_rules! github_at_forwarders {
1010    (
1011        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1012        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1013    ) => {
1014        impl<'a, R: ProcessRunner> GitHubAt<'a, R> {
1015            $(
1016                #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($bn), "`.")]
1017                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1018                    self.gh.$bn($($ba),*).await
1019                }
1020            )*
1021            $(
1022                #[doc = concat!("Bound form of [`GitHub`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1023                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1024                    self.gh.$dn(self.dir, $($da),*).await
1025                }
1026            )*
1027        }
1028    };
1029}
1030
1031github_at_forwarders! {
1032    bare {
1033        fn run(args: &[String]) -> Result<String>;
1034        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1035        fn run_args(args: &[&str]) -> Result<String>;
1036        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1037        fn version() -> Result<String>;
1038        fn auth_status() -> Result<bool>;
1039        fn api(endpoint: &str) -> Result<String>;
1040    }
1041    dir {
1042        fn repo_view() -> Result<Repo>;
1043        fn pr_list() -> Result<Vec<PullRequest>>;
1044        fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
1045        fn pr_view(number: u64) -> Result<PullRequest>;
1046        fn issue_list() -> Result<Vec<Issue>>;
1047        fn pr_create(spec: PrCreate) -> Result<String>;
1048        fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
1049        fn pr_ready(number: u64) -> Result<()>;
1050        fn pr_close(number: u64, delete_branch: bool) -> Result<()>;
1051        fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
1052        fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
1053        fn pr_comment(number: u64, body: &str) -> Result<String>;
1054        fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
1055        fn pr_feedback(number: u64) -> Result<PrFeedback>;
1056        fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
1057        fn run_view(id: u64) -> Result<WorkflowRun>;
1058        fn run_watch(id: u64) -> Result<WorkflowRun>;
1059        fn issue_create(title: &str, body: &str) -> Result<String>;
1060        fn issue_view(number: u64) -> Result<Issue>;
1061        fn release_list() -> Result<Vec<Release>>;
1062        fn release_view(tag: &str) -> Result<Release>;
1063    }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
1070
1071    #[test]
1072    fn binary_name_is_gh() {
1073        assert_eq!(BINARY, "gh");
1074    }
1075
1076    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
1077    #[allow(dead_code)]
1078    fn bound_view_is_copy_for_default_runner() {
1079        fn assert_copy<T: Copy>() {}
1080        assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
1081    }
1082
1083    // The bound view (`gh.at(dir)`) must produce byte-identical argv to the
1084    // dir-taking call.
1085    #[tokio::test]
1086    async fn bound_view_matches_dir_taking_calls() {
1087        let dir = Path::new("/repo");
1088        let rec = RecordingRunner::replying(Reply::ok("[]"));
1089        let gh = GitHub::with_runner(&rec);
1090
1091        gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
1092        gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
1093        // One of the new lifecycle methods.
1094        gh.run_list(dir, 3, None).await.unwrap();
1095        gh.at(dir).run_list(3, None).await.unwrap();
1096
1097        let calls = rec.calls();
1098        assert_eq!(calls[0].args_str(), calls[1].args_str());
1099        assert_eq!(calls[2].args_str(), calls[3].args_str());
1100        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
1101    }
1102
1103    #[tokio::test]
1104    async fn run_args_forwards_str_slices() {
1105        let gh =
1106            GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
1107        assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
1108    }
1109
1110    // Hermetic: real pr_list() arg-building + JSON deserialization against canned
1111    // output — no `gh` binary or network needed, so this runs on CI.
1112    #[tokio::test]
1113    async fn pr_list_parses_scripted_json() {
1114        let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
1115        let gh =
1116            GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "list"], Reply::ok(json)));
1117        let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
1118        assert_eq!(prs.len(), 1);
1119        assert_eq!(prs[0].number, 7);
1120        assert_eq!(prs[0].base_ref_name, "main");
1121    }
1122
1123    // Hermetic: auth_status reflects the exit code without erroring. ANY non-zero
1124    // exit — not just the documented 1 — must read as `false`, never an error
1125    // (an unusual exit code must not be mistaken for a hard failure).
1126    #[tokio::test]
1127    async fn auth_status_reads_exit_code() {
1128        let yes = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::ok("")));
1129        assert!(yes.auth_status().await.unwrap());
1130        let no = GitHub::with_runner(
1131            ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
1132        );
1133        assert!(!no.auth_status().await.unwrap());
1134        // An unexpected exit code (e.g. 2) is still just "not authenticated".
1135        let weird =
1136            GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
1137        assert!(!weird.auth_status().await.unwrap());
1138    }
1139
1140    // Regression guard for the timeout fix: a timed-out auth check must error,
1141    // not silently report "not authenticated" (the old hand-rolled mapping bug).
1142    // Relies on processkit surfacing a timed-out run as `Error::Timeout`.
1143    #[tokio::test]
1144    async fn auth_status_errors_on_timeout() {
1145        let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::timeout()));
1146        assert!(matches!(
1147            gh.auth_status().await.unwrap_err(),
1148            Error::Timeout { .. }
1149        ));
1150    }
1151
1152    // pr_create appends `--base <branch>` when given one, and returns the trimmed
1153    // PR URL. The exact command (incl. --base) is the only scripted rule.
1154    #[tokio::test]
1155    async fn pr_create_appends_base_and_returns_url() {
1156        let gh = GitHub::with_runner(ScriptedRunner::new().on(
1157            [
1158                "gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
1159            ],
1160            Reply::ok("https://gh/pr/1\n"),
1161        ));
1162        let url = gh
1163            .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
1164            .await
1165            .expect("should build `pr create … --base main`");
1166        assert_eq!(url, "https://gh/pr/1");
1167    }
1168
1169    // With an explicit head, `pr_create` inserts `--head <branch>` before
1170    // `--base` — so a PR can target an arbitrary source→target pair.
1171    #[tokio::test]
1172    async fn pr_create_appends_head_and_base() {
1173        use processkit::testing::RecordingRunner;
1174        let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
1175        let gh = GitHub::with_runner(&rec);
1176        gh.pr_create(
1177            Path::new("/repo"),
1178            PrCreate::new("T", "B").head("feat/x").base("main"),
1179        )
1180        .await
1181        .expect("pr_create");
1182        assert_eq!(
1183            rec.only_call().args_str(),
1184            [
1185                "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
1186            ]
1187        );
1188    }
1189
1190    // pr_list_for_branch filters by head + base and parses the PR list (title +
1191    // url available on each result).
1192    #[tokio::test]
1193    async fn pr_list_for_branch_filters_and_parses() {
1194        use processkit::testing::RecordingRunner;
1195        let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
1196        let rec = RecordingRunner::replying(Reply::ok(json));
1197        let gh = GitHub::with_runner(&rec);
1198        let prs = gh
1199            .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
1200            .await
1201            .expect("pr_list_for_branch");
1202        assert_eq!(prs.len(), 1);
1203        assert_eq!(prs[0].title, "Merge feat");
1204        assert_eq!(prs[0].url, "https://gh/pr/9");
1205        assert_eq!(
1206            rec.only_call().args_str(),
1207            [
1208                "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
1209                "100", "--json", PR_FIELDS
1210            ]
1211        );
1212    }
1213
1214    // The list methods pin an explicit `--limit 100` so the CLI's default page
1215    // size (30) does not silently truncate the result.
1216    #[tokio::test]
1217    async fn list_methods_pin_limit_100() {
1218        let rec = RecordingRunner::replying(Reply::ok("[]"));
1219        let gh = GitHub::with_runner(&rec);
1220        gh.pr_list(Path::new("/r")).await.expect("pr_list");
1221        gh.issue_list(Path::new("/r")).await.expect("issue_list");
1222        gh.release_list(Path::new("/r"))
1223            .await
1224            .expect("release_list");
1225        let calls = rec.calls();
1226        assert_eq!(
1227            calls[0].args_str(),
1228            ["pr", "list", "--limit", "100", "--json", PR_FIELDS]
1229        );
1230        assert_eq!(
1231            calls[1].args_str(),
1232            [
1233                "issue",
1234                "list",
1235                "--limit",
1236                "100",
1237                "--json",
1238                ISSUE_LIST_FIELDS
1239            ]
1240        );
1241        assert_eq!(
1242            calls[2].args_str(),
1243            [
1244                "release",
1245                "list",
1246                "--limit",
1247                "100",
1248                "--json",
1249                RELEASE_LIST_FIELDS
1250            ]
1251        );
1252    }
1253
1254    // Without a base, `pr_create` must omit `--base` entirely. RecordingRunner
1255    // captures the exact invocation (and `&rec` plumbs through CliClient), so we
1256    // can assert flag *absence* and the cwd — which prefix matching can't.
1257    #[tokio::test]
1258    async fn pr_create_omits_base_when_none() {
1259        use processkit::testing::RecordingRunner;
1260        let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
1261        let gh = GitHub::with_runner(&rec);
1262        let url = gh
1263            .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
1264            .await
1265            .expect("pr_create");
1266        assert_eq!(url, "https://gh/pr/2");
1267
1268        let call = rec.only_call();
1269        assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
1270        assert_eq!(
1271            call.args_str(),
1272            ["pr", "create", "--title", "T", "--body", "B"]
1273        );
1274        assert!(!call.has_flag("--base"), "no base was given");
1275        assert!(!call.has_flag("--head"), "no head was given");
1276    }
1277
1278    // The injection guard on gh's exposed positionals.
1279    #[tokio::test]
1280    async fn flag_like_positionals_are_rejected_before_spawning() {
1281        let rec = RecordingRunner::replying(Reply::ok(""));
1282        let gh = GitHub::with_runner(&rec);
1283        assert!(gh.api("-evil").await.is_err());
1284        assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
1285        assert!(gh.api("").await.is_err(), "empty refused too");
1286        assert!(rec.calls().is_empty(), "nothing may spawn");
1287    }
1288
1289    // pr_merge builds the strategy flag plus the optional --auto/--delete-branch.
1290    #[tokio::test]
1291    async fn pr_merge_builds_strategy_and_flags() {
1292        let rec = RecordingRunner::replying(Reply::ok(""));
1293        let gh = GitHub::with_runner(&rec);
1294        gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
1295            .await
1296            .expect("pr_merge");
1297        assert_eq!(
1298            rec.only_call().args_str(),
1299            ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
1300        );
1301
1302        let bare = RecordingRunner::replying(Reply::ok(""));
1303        let gh = GitHub::with_runner(&bare);
1304        gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
1305            .await
1306            .expect("pr_merge");
1307        let call = bare.only_call();
1308        assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
1309        assert!(!call.has_flag("--auto"));
1310        assert!(!call.has_flag("--delete-branch"));
1311    }
1312
1313    #[tokio::test]
1314    async fn pr_ready_and_close_build_args() {
1315        let rec = RecordingRunner::replying(Reply::ok(""));
1316        let gh = GitHub::with_runner(&rec);
1317        gh.pr_ready(Path::new("/r"), 3).await.expect("pr_ready");
1318        gh.pr_close(Path::new("/r"), 3, true).await.expect("close");
1319        gh.pr_close(Path::new("/r"), 4, false).await.expect("close");
1320        let calls = rec.calls();
1321        assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
1322        assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
1323        assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
1324    }
1325
1326    // gh signals the checks outcome via exit code (0 pass / 8 pending / 1 some
1327    // failed) but emits the same JSON for all three — all must parse. Other
1328    // exits (and timeouts) are genuine errors.
1329    #[tokio::test]
1330    async fn pr_checks_parses_all_outcome_exit_codes() {
1331        let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
1332            "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
1333        for reply in [
1334            Reply::ok(json),
1335            Reply::fail(8, "checks pending").with_stdout(json),
1336            Reply::fail(1, "some checks failed").with_stdout(json),
1337        ] {
1338            let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
1339            let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
1340            assert_eq!(checks.len(), 1);
1341            assert_eq!(checks[0].bucket, CheckBucket::Pass);
1342        }
1343
1344        // A PR with no checks at all: gh exits 1 with NO JSON and a
1345        // "no checks reported" message — an empty list, not an error. Matched
1346        // case-insensitively, so a capitalized variant is still the empty case.
1347        for stderr in [
1348            "no checks reported on the 'feat/x' branch",
1349            "No Checks Reported on the 'feat/x' branch",
1350        ] {
1351            let gh = GitHub::with_runner(
1352                ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
1353            );
1354            assert!(
1355                gh.pr_checks(Path::new("."), 7)
1356                    .await
1357                    .expect("no checks → empty")
1358                    .is_empty(),
1359                "no-checks must read as empty for stderr {stderr:?}"
1360            );
1361        }
1362        // …while a bare exit 1 for a different reason stays an error.
1363        let gh = GitHub::with_runner(ScriptedRunner::new().on(
1364            ["gh", "pr", "checks"],
1365            Reply::fail(1, "no pull requests found for branch 'feat/x'"),
1366        ));
1367        assert!(matches!(
1368            gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1369            Error::Exit { .. }
1370        ));
1371
1372        // Exit 4 (auth required) is a real failure, not an outcome.
1373        let gh = GitHub::with_runner(
1374            ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
1375        );
1376        assert!(matches!(
1377            gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1378            Error::Exit { .. }
1379        ));
1380
1381        let gh =
1382            GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
1383        assert!(matches!(
1384            gh.pr_checks(Path::new("."), 7).await.unwrap_err(),
1385            Error::Timeout { .. }
1386        ));
1387    }
1388
1389    // Each review action maps to its flag; the body is carried on the action
1390    // (approve's is optional and omitted when absent).
1391    #[tokio::test]
1392    async fn pr_review_builds_action_args() {
1393        let rec = RecordingRunner::replying(Reply::ok(""));
1394        let gh = GitHub::with_runner(&rec);
1395        gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
1396            .await
1397            .expect("approve");
1398        gh.pr_review(
1399            Path::new("/r"),
1400            7,
1401            ReviewAction::request_changes("fix the parser"),
1402        )
1403        .await
1404        .expect("request changes");
1405        gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
1406            .await
1407            .expect("comment");
1408        let calls = rec.calls();
1409        assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
1410        assert!(!calls[0].has_flag("--body"));
1411        assert_eq!(
1412            calls[1].args_str(),
1413            [
1414                "pr",
1415                "review",
1416                "7",
1417                "--request-changes",
1418                "--body",
1419                "fix the parser"
1420            ]
1421        );
1422        assert_eq!(
1423            calls[2].args_str(),
1424            ["pr", "review", "7", "--comment", "--body", "nice"]
1425        );
1426    }
1427
1428    // `approve().with_body(..)` attaches the optional approve message, emitting
1429    // `--approve --body <body>`; the accessors read the parts back.
1430    #[tokio::test]
1431    async fn pr_review_approve_with_body() {
1432        let action = ReviewAction::approve().with_body("LGTM");
1433        assert_eq!(action.kind(), ReviewKind::Approve);
1434        assert_eq!(action.body(), Some("LGTM"));
1435
1436        let rec = RecordingRunner::replying(Reply::ok(""));
1437        let gh = GitHub::with_runner(&rec);
1438        gh.pr_review(Path::new("/r"), 7, action)
1439            .await
1440            .expect("approve with body");
1441        assert_eq!(
1442            rec.only_call().args_str(),
1443            ["pr", "review", "7", "--approve", "--body", "LGTM"]
1444        );
1445    }
1446
1447    #[tokio::test]
1448    async fn pr_comment_and_issue_create_return_urls() {
1449        let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
1450        let gh = GitHub::with_runner(&rec);
1451        assert_eq!(
1452            gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
1453            "https://gh/x"
1454        );
1455        assert_eq!(
1456            gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
1457            "https://gh/x"
1458        );
1459        let calls = rec.calls();
1460        assert_eq!(
1461            calls[0].args_str(),
1462            ["pr", "comment", "7", "--body", "hello"]
1463        );
1464        assert_eq!(
1465            calls[1].args_str(),
1466            ["issue", "create", "--title", "T", "--body", "B"]
1467        );
1468    }
1469
1470    // pr_edit emits only the flags the caller set. The flag-VALUE slots
1471    // (`--title <t>`, `--body <b>`) are passed verbatim — no argv-guard needed
1472    // since gh consumes the next token as a value, not as a flag.
1473    #[tokio::test]
1474    async fn pr_edit_emits_only_provided_fields() {
1475        let rec = RecordingRunner::replying(Reply::ok(""));
1476        let gh = GitHub::with_runner(&rec);
1477
1478        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
1479            .await
1480            .expect("title-only edit");
1481        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
1482            .await
1483            .expect("body-only edit");
1484        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
1485            .await
1486            .expect("both-fields edit");
1487
1488        let calls = rec.calls();
1489        assert_eq!(
1490            calls[0].args_str(),
1491            ["pr", "edit", "7", "--title", "New title"]
1492        );
1493        assert_eq!(
1494            calls[1].args_str(),
1495            ["pr", "edit", "7", "--body", "New body"]
1496        );
1497        assert_eq!(
1498            calls[2].args_str(),
1499            ["pr", "edit", "7", "--title", "T", "--body", "B"]
1500        );
1501    }
1502
1503    // An empty string is a real value (clears the field) — it must reach the
1504    // CLI as `--title ""`, not be silently dropped. The argv is asserted
1505    // byte-for-byte so a future "treat empty as None" regression would
1506    // surface here.
1507    #[tokio::test]
1508    async fn pr_edit_some_empty_string_clears_field() {
1509        let rec = RecordingRunner::replying(Reply::ok(""));
1510        let gh = GitHub::with_runner(&rec);
1511        gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
1512            .await
1513            .expect("empty title");
1514        assert_eq!(
1515            rec.only_call().args_str(),
1516            ["pr", "edit", "7", "--title", ""]
1517        );
1518    }
1519
1520    #[tokio::test]
1521    async fn with_credentials_injects_gh_token_and_default_does_not() {
1522        // With a provider: the token is set as GH_TOKEN on the command — and never
1523        // appears in argv (so it can't leak through `ps`).
1524        let rec = RecordingRunner::replying(Reply::ok("[]"));
1525        let gh = GitHub::with_runner(&rec)
1526            .with_credentials(Arc::new(StaticCredential::token("tok-123")));
1527        gh.pr_list(Path::new("/r")).await.unwrap();
1528        let call = rec.only_call();
1529        let token = call
1530            .envs
1531            .iter()
1532            .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1533            .and_then(|(_, v)| v.as_ref())
1534            .and_then(|v| v.to_str());
1535        assert_eq!(
1536            token,
1537            Some("tok-123"),
1538            "provider token injected as GH_TOKEN"
1539        );
1540        assert!(
1541            !call.args_str().iter().any(|a| a.contains("tok-123")),
1542            "secret must never appear in argv"
1543        );
1544
1545        // Without a provider: no GH_TOKEN injected — ambient `gh` auth is unchanged.
1546        let rec = RecordingRunner::replying(Reply::ok("[]"));
1547        let gh = GitHub::with_runner(&rec);
1548        gh.pr_list(Path::new("/r")).await.unwrap();
1549        assert!(
1550            !rec.only_call()
1551                .envs
1552                .iter()
1553                .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
1554            "no provider → no token env (ambient gh auth)"
1555        );
1556    }
1557
1558    // The `with_token` convenience is the common path: a static token, no `Arc`/
1559    // `StaticCredential` ceremony, injected as GH_TOKEN.
1560    #[tokio::test]
1561    async fn with_token_convenience_injects_gh_token() {
1562        let rec = RecordingRunner::replying(Reply::ok("[]"));
1563        let gh = GitHub::with_runner(&rec).with_token("tok-conv");
1564        gh.pr_list(Path::new("/r")).await.unwrap();
1565        let call = rec.only_call();
1566        let token = call
1567            .envs
1568            .iter()
1569            .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1570            .and_then(|(_, v)| v.as_ref())
1571            .and_then(|v| v.to_str());
1572        assert_eq!(token, Some("tok-conv"));
1573    }
1574
1575    // A provider that yields `Ok(None)` defers to ambient auth: no GH_TOKEN is
1576    // injected, exactly as if no provider were attached. Pins the None=ambient
1577    // contract end-to-end (not just at the provider level).
1578    #[tokio::test]
1579    async fn provider_returning_none_falls_back_to_ambient() {
1580        let rec = RecordingRunner::replying(Reply::ok("[]"));
1581        let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
1582        gh.pr_list(Path::new("/r")).await.unwrap();
1583        assert!(
1584            !rec.only_call()
1585                .envs
1586                .iter()
1587                .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
1588            "Ok(None) provider injects no token (ambient)"
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn injected_token_overrides_ambient_default_env() {
1594        // A provider token is applied after any `default_env("GH_TOKEN", …)`, so it
1595        // wins — "I supplied a provider, use it" beats an ambient env default.
1596        let rec = RecordingRunner::replying(Reply::ok("[]"));
1597        let gh = GitHub::with_runner(&rec)
1598            .default_env("GH_TOKEN", "ambient-token")
1599            .with_credentials(Arc::new(StaticCredential::token("provider-token")));
1600        gh.pr_list(Path::new("/r")).await.unwrap();
1601        let call = rec.only_call();
1602        let winner = call
1603            .envs
1604            .iter()
1605            .rev()
1606            .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
1607            .and_then(|(_, v)| v.as_ref())
1608            .and_then(|v| v.to_str());
1609        assert_eq!(winner, Some("provider-token"), "provider token wins");
1610    }
1611
1612    #[tokio::test]
1613    async fn pr_feedback_requests_reviews_and_comments() {
1614        let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
1615            "body":"","submittedAt":""}],"comments":[]}"#;
1616        let rec =
1617            RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
1618        let gh = GitHub::with_runner(&rec);
1619        let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
1620        assert_eq!(feedback.reviews[0].author, "a");
1621        assert!(feedback.comments.is_empty());
1622        assert_eq!(
1623            rec.only_call().args_str(),
1624            ["pr", "view", "7", "--json", "reviews,comments"]
1625        );
1626    }
1627
1628    // run_list appends --branch only when given one.
1629    #[tokio::test]
1630    async fn run_list_appends_branch_only_when_some() {
1631        let rec = RecordingRunner::replying(Reply::ok("[]"));
1632        let gh = GitHub::with_runner(&rec);
1633        gh.run_list(Path::new("/r"), 5, None).await.expect("list");
1634        gh.run_list(Path::new("/r"), 5, Some("main".into()))
1635            .await
1636            .expect("list");
1637        let calls = rec.calls();
1638        assert_eq!(
1639            calls[0].args_str(),
1640            ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
1641        );
1642        assert_eq!(
1643            calls[1].args_str(),
1644            [
1645                "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
1646            ]
1647        );
1648    }
1649
1650    // run_watch blocks on `run watch` (no `--exit-status`, so a failed run still
1651    // exits 0 — the outcome is read via the follow-up view, the only channel
1652    // that can distinguish failed from cancelled).
1653    #[tokio::test]
1654    async fn run_watch_then_views_final_state() {
1655        let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
1656            "status":"completed","conclusion":"failure","workflowName":"CI",
1657            "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
1658        let rec = RecordingRunner::new(
1659            ScriptedRunner::new()
1660                .on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
1661                .on(["gh", "run", "view"], Reply::ok(json)),
1662        );
1663        let gh = GitHub::with_runner(&rec);
1664        let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
1665        assert_eq!(run.conclusion, "failure");
1666        let calls = rec.calls();
1667        assert_eq!(calls.len(), 2);
1668        assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
1669        assert_eq!(
1670            calls[1].args_str(),
1671            ["run", "view", "42", "--json", RUN_FIELDS]
1672        );
1673    }
1674
1675    // A timed-out or failing watch must error — NOT report a half-finished run
1676    // via the follow-up view. (`output` does not error on a timeout; the
1677    // `ensure_success` in run_watch is what surfaces it.)
1678    #[tokio::test]
1679    async fn run_watch_surfaces_timeout_and_watch_errors() {
1680        let rec = RecordingRunner::new(
1681            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
1682        );
1683        let gh = GitHub::with_runner(&rec);
1684        assert!(matches!(
1685            gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1686            Error::Timeout { .. }
1687        ));
1688        assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
1689
1690        let gh = GitHub::with_runner(
1691            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
1692        );
1693        assert!(matches!(
1694            gh.run_watch(Path::new("."), 42).await.unwrap_err(),
1695            Error::Exit { .. }
1696        ));
1697    }
1698
1699    // Client-level cancellation (processkit 0.8 `cancellation` feature): a client
1700    // built with `default_cancel_on(token)` threads the token into every command
1701    // it builds, so a long `run_watch` parks until the token fires, then surfaces
1702    // `Error::Cancelled` — a controller cancels without touching the call site
1703    // (zero new vcs-* API). Hermetic via `Reply::pending()` (parks until the
1704    // command's token fires) on a paused clock: the 1 h `timeout` elapses
1705    // instantly while the call is parked, proving it does not resolve early.
1706    #[tokio::test(start_paused = true)]
1707    async fn run_watch_cancels_via_client_default_token() {
1708        use processkit::CancellationToken;
1709        let token = CancellationToken::new();
1710        let gh =
1711            GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
1712                .default_cancel_on(token.clone());
1713        let call = gh.run_watch(Path::new("."), 42);
1714        tokio::pin!(call);
1715        assert!(
1716            tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
1717                .await
1718                .is_err(),
1719            "run_watch must park until the token fires"
1720        );
1721        token.cancel();
1722        match call.await {
1723            Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
1724            other => panic!("expected Error::Cancelled, got {other:?}"),
1725        }
1726    }
1727
1728    #[tokio::test]
1729    async fn release_view_requests_view_fields() {
1730        let json = r#"{"tagName":"v1","name":"","body":"notes","url":"u",
1731            "publishedAt":"p","isDraft":false,"isPrerelease":false}"#;
1732        let rec = RecordingRunner::new(
1733            ScriptedRunner::new().on(["gh", "release", "view"], Reply::ok(json)),
1734        );
1735        let gh = GitHub::with_runner(&rec);
1736        let release = gh
1737            .release_view(Path::new("."), "v1")
1738            .await
1739            .expect("release_view");
1740        assert_eq!(release.tag_name, "v1");
1741        assert_eq!(release.body, "notes");
1742        assert_eq!(
1743            rec.only_call().args_str(),
1744            ["release", "view", "v1", "--json", RELEASE_VIEW_FIELDS]
1745        );
1746    }
1747
1748    // repo_view builds the --json request and flattens gh's nested owner/branch
1749    // objects into the public Repo.
1750    #[tokio::test]
1751    async fn repo_view_parses_scripted_json() {
1752        let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
1753        let gh =
1754            GitHub::with_runner(ScriptedRunner::new().on(["gh", "repo", "view"], Reply::ok(json)));
1755        let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
1756        assert_eq!(repo.owner, "o");
1757        assert_eq!(repo.default_branch, "main");
1758        assert!(!repo.is_private);
1759    }
1760
1761    #[cfg(feature = "mock")]
1762    #[tokio::test]
1763    async fn consumer_mocks_the_interface() {
1764        let mut mock = MockGitHubApi::new();
1765        mock.expect_auth_status().returning(|| Ok(true));
1766        assert!(mock.auth_status().await.unwrap());
1767    }
1768}
1769
1770// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
1771#[doc = include_str!("../docs/github.md")]
1772#[allow(rustdoc::broken_intra_doc_links)]
1773pub mod guide {}