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::{
13    Author, IssueLabel, IssueMilestone, IssueSummary, IssueType, IssueView, OpenPr, PrSummary,
14    PrView, pr_state,
15};
16
17/// Performs GitHub pull-request operations via `gh`.
18pub trait GhClient {
19    /// Lists open issues for the repository at `dir`.
20    ///
21    /// Defaulted so third-party [`GhClient`] implementations stay
22    /// source-compatible; clients that support the issue workflow override it.
23    fn list_open_issues(&self, _dir: &Path) -> Result<Vec<IssueSummary>> {
24        Err(Error::GhUnavailable(
25            "GitHub issue operations are not supported by this client".into(),
26        ))
27    }
28
29    /// Views the issue identified by `target` (a number or URL).
30    ///
31    /// Defaulted for the same reason as [`GhClient::list_open_issues`].
32    fn view_issue(&self, _dir: &Path, _target: &str) -> Result<IssueView> {
33        Err(Error::GhUnavailable(
34            "GitHub issue operations are not supported by this client".into(),
35        ))
36    }
37
38    /// Lists open PRs for the repository at `dir`.
39    fn list_open_prs(&self, dir: &Path) -> Result<Vec<PrSummary>>;
40
41    /// Views the PR identified by `target` (a number, URL, or head branch).
42    fn view_pr(&self, dir: &Path, target: &str) -> Result<PrView>;
43
44    /// The repository's default branch (`gh repo view --json defaultBranchRef`),
45    /// or `None` on any failure (kept non-fatal so trunk detection can fall back
46    /// to local git state offline).
47    fn default_branch(&self, dir: &Path) -> Result<Option<String>>;
48
49    /// The open PR whose head is `branch`, if any.
50    fn find_pr_for_branch(&self, dir: &Path, branch: &str) -> Result<Option<OpenPr>>;
51
52    /// Runs `gh pr create` with the prebuilt `args`, returning stdout (the URL
53    /// line is parsed by the caller). Args are typically built by
54    /// `sendit::build_create_args`.
55    fn create_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
56
57    /// Runs `gh pr edit` with the prebuilt `args`, returning stdout. Args are
58    /// typically built by `sendit::build_edit_args`.
59    fn edit_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
60
61    /// Lists open PR numbers (for completion; best-effort).
62    fn open_pr_numbers(&self, dir: &Path) -> Result<Vec<u64>> {
63        Ok(self
64            .list_open_prs(dir)?
65            .into_iter()
66            .map(|p| p.number)
67            .collect())
68    }
69}
70
71/// The production [`GhClient`] that spawns the real `gh` binary.
72#[derive(Debug, Clone, Copy, Default)]
73pub struct RealGh;
74
75impl GhClient for RealGh {
76    fn list_open_issues(&self, dir: &Path) -> Result<Vec<IssueSummary>> {
77        let output = run_gh(
78            dir,
79            &[
80                "issue",
81                "list",
82                "--state",
83                "open",
84                "--limit",
85                "100",
86                "--json",
87                "number,title,state,labels,issueType,milestone,createdAt,url",
88            ],
89        )?;
90        serde_json::from_str(&output).map_err(Error::from)
91    }
92
93    fn view_issue(&self, dir: &Path, target: &str) -> Result<IssueView> {
94        let output = run_gh(
95            dir,
96            &[
97                "issue",
98                "view",
99                target,
100                "--json",
101                "number,title,body,state,labels,issueType,milestone,createdAt,updatedAt,url",
102            ],
103        )?;
104        serde_json::from_str(&output).map_err(Error::from)
105    }
106
107    fn list_open_prs(&self, dir: &Path) -> Result<Vec<PrSummary>> {
108        let output = run_gh(
109            dir,
110            &[
111                "pr",
112                "list",
113                "--state",
114                "open",
115                "--json",
116                "number,title,author,state,isDraft,headRefName,createdAt",
117            ],
118        )?;
119        serde_json::from_str(&output).map_err(Error::from)
120    }
121
122    fn view_pr(&self, dir: &Path, target: &str) -> Result<PrView> {
123        let output = run_gh(
124            dir,
125            &[
126                "pr",
127                "view",
128                target,
129                "--json",
130                "number,title,state,isDraft,headRefName,baseRefName,url",
131            ],
132        )?;
133        serde_json::from_str(&output).map_err(Error::from)
134    }
135
136    fn default_branch(&self, dir: &Path) -> Result<Option<String>> {
137        // Non-fatal: any failure (no `gh`, no remote, offline) falls back to
138        // local trunk detection, so map errors to `None` rather than propagate.
139        match run_gh(dir, &["repo", "view", "--json", "defaultBranchRef"]) {
140            Ok(output) => Ok(types::parse_default_branch(&output)),
141            Err(_) => Ok(None),
142        }
143    }
144
145    fn find_pr_for_branch(&self, dir: &Path, branch: &str) -> Result<Option<OpenPr>> {
146        let output = run_gh(
147            dir,
148            &[
149                "pr",
150                "list",
151                "--head",
152                branch,
153                "--state",
154                "open",
155                "--json",
156                "number,url,state,isDraft",
157            ],
158        )?;
159        let prs: Vec<OpenPr> = serde_json::from_str(&output).map_err(Error::from)?;
160        Ok(prs.into_iter().next())
161    }
162
163    fn create_pr(&self, dir: &Path, args: &[String]) -> Result<String> {
164        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
165        run_gh(dir, &argv)
166    }
167
168    fn edit_pr(&self, dir: &Path, args: &[String]) -> Result<String> {
169        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
170        run_gh(dir, &argv)
171    }
172}
173
174/// Runs `gh` in `dir`, mapping a missing binary or auth failure to
175/// [`Error::GhUnavailable`] and other failures to [`Error::Subprocess`].
176fn run_gh(dir: &Path, args: &[&str]) -> Result<String> {
177    let result = Command::new("gh").current_dir(dir).args(args).output();
178    let output = match result {
179        Ok(output) => output,
180        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
181            return Err(Error::GhUnavailable(
182                "gh is not installed; install it and run `gh auth login`".into(),
183            ));
184        }
185        Err(e) => return Err(Error::GhUnavailable(format!("failed to run gh: {e}"))),
186    };
187    if output.status.success() {
188        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
189    }
190    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
191    let lowered = stderr.to_ascii_lowercase();
192    if lowered.contains("auth")
193        || lowered.contains("logged in")
194        || lowered.contains("gh auth login")
195    {
196        Err(Error::GhUnavailable(format!(
197            "{stderr}\nrun `gh auth login`"
198        )))
199    } else {
200        Err(Error::Subprocess {
201            program: "gh".into(),
202            stderr,
203        })
204    }
205}