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