1pub 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
14pub trait GhClient {
16 fn list_open_prs(&self, dir: &Path) -> Result<Vec<PrSummary>>;
18
19 fn view_pr(&self, dir: &Path, target: &str) -> Result<PrView>;
21
22 fn default_branch(&self, dir: &Path) -> Result<Option<String>>;
26
27 fn find_pr_for_branch(&self, dir: &Path, branch: &str) -> Result<Option<OpenPr>>;
29
30 fn create_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
34
35 fn edit_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
38
39 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#[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 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
121fn 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}