Skip to main content

wt/gh/
mod.rs

1//! The GitHub boundary (spec §4): all pull-request operations shell out to the
2//! `gh` CLI. [`GhClient`] isolates this so tests can inject a fake; [`RealGh`]
3//! spawns the real binary. A missing or unauthenticated `gh` yields
4//! [`Error::GhUnavailable`] with an actionable message (§12).
5
6pub mod types;
7
8use std::path::Path;
9use std::process::Command;
10
11use crate::error::{Error, Result};
12pub use types::{Author, OpenPr, PrSummary, PrView, pr_state};
13
14/// Performs GitHub pull-request operations via `gh`.
15pub trait GhClient {
16    /// Lists open PRs for the repository at `dir`.
17    fn list_open_prs(&self, dir: &Path) -> Result<Vec<PrSummary>>;
18
19    /// Views the PR identified by `target` (a number, URL, or head branch).
20    fn view_pr(&self, dir: &Path, target: &str) -> Result<PrView>;
21
22    /// The repository's default branch (`gh repo view --json defaultBranchRef`),
23    /// or `None` on any failure (kept non-fatal so trunk detection can fall back
24    /// to local git state offline).
25    fn default_branch(&self, dir: &Path) -> Result<Option<String>>;
26
27    /// The open PR whose head is `branch`, if any.
28    fn find_pr_for_branch(&self, dir: &Path, branch: &str) -> Result<Option<OpenPr>>;
29
30    /// Runs `gh pr create` with the prebuilt `args`, returning stdout (the URL
31    /// line is parsed by the caller). Args are typically built by
32    /// `sendit::build_create_args`.
33    fn create_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
34
35    /// Runs `gh pr edit` with the prebuilt `args`, returning stdout. Args are
36    /// typically built by `sendit::build_edit_args`.
37    fn edit_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
38
39    /// Lists open PR numbers (for completion; best-effort).
40    fn open_pr_numbers(&self, dir: &Path) -> Result<Vec<u64>> {
41        Ok(self
42            .list_open_prs(dir)?
43            .into_iter()
44            .map(|p| p.number)
45            .collect())
46    }
47}
48
49/// The production [`GhClient`] that spawns the real `gh` binary.
50#[derive(Debug, Clone, Copy, Default)]
51pub struct RealGh;
52
53impl GhClient for RealGh {
54    fn list_open_prs(&self, dir: &Path) -> Result<Vec<PrSummary>> {
55        let output = run_gh(
56            dir,
57            &[
58                "pr",
59                "list",
60                "--state",
61                "open",
62                "--json",
63                "number,title,author,state,isDraft,headRefName,createdAt",
64            ],
65        )?;
66        serde_json::from_str(&output).map_err(Error::from)
67    }
68
69    fn view_pr(&self, dir: &Path, target: &str) -> Result<PrView> {
70        let output = run_gh(
71            dir,
72            &[
73                "pr",
74                "view",
75                target,
76                "--json",
77                "number,title,state,isDraft,headRefName,baseRefName,url",
78            ],
79        )?;
80        serde_json::from_str(&output).map_err(Error::from)
81    }
82
83    fn default_branch(&self, dir: &Path) -> Result<Option<String>> {
84        // Non-fatal: any failure (no `gh`, no remote, offline) falls back to
85        // local trunk detection, so map errors to `None` rather than propagate.
86        match run_gh(dir, &["repo", "view", "--json", "defaultBranchRef"]) {
87            Ok(output) => Ok(types::parse_default_branch(&output)),
88            Err(_) => Ok(None),
89        }
90    }
91
92    fn find_pr_for_branch(&self, dir: &Path, branch: &str) -> Result<Option<OpenPr>> {
93        let output = run_gh(
94            dir,
95            &[
96                "pr",
97                "list",
98                "--head",
99                branch,
100                "--state",
101                "open",
102                "--json",
103                "number,url,state,isDraft",
104            ],
105        )?;
106        let prs: Vec<OpenPr> = serde_json::from_str(&output).map_err(Error::from)?;
107        Ok(prs.into_iter().next())
108    }
109
110    fn create_pr(&self, dir: &Path, args: &[String]) -> Result<String> {
111        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
112        run_gh(dir, &argv)
113    }
114
115    fn edit_pr(&self, dir: &Path, args: &[String]) -> Result<String> {
116        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
117        run_gh(dir, &argv)
118    }
119}
120
121/// Runs `gh` in `dir`, mapping a missing binary or auth failure to
122/// [`Error::GhUnavailable`] and other failures to [`Error::Subprocess`].
123fn run_gh(dir: &Path, args: &[&str]) -> Result<String> {
124    let result = Command::new("gh").current_dir(dir).args(args).output();
125    let output = match result {
126        Ok(output) => output,
127        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
128            return Err(Error::GhUnavailable(
129                "gh is not installed; install it and run `gh auth login`".into(),
130            ));
131        }
132        Err(e) => return Err(Error::GhUnavailable(format!("failed to run gh: {e}"))),
133    };
134    if output.status.success() {
135        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
136    }
137    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
138    let lowered = stderr.to_ascii_lowercase();
139    if lowered.contains("auth")
140        || lowered.contains("logged in")
141        || lowered.contains("gh auth login")
142    {
143        Err(Error::GhUnavailable(format!(
144            "{stderr}\nrun `gh auth login`"
145        )))
146    } else {
147        Err(Error::Subprocess {
148            program: "gh".into(),
149            stderr,
150        })
151    }
152}