Skip to main content

vcs_github/
lib.rs

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