1pub 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
17pub trait GhClient {
19 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 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 fn list_open_prs(&self, dir: &Path) -> Result<Vec<PrSummary>>;
40
41 fn view_pr(&self, dir: &Path, target: &str) -> Result<PrView>;
43
44 fn default_branch(&self, dir: &Path) -> Result<Option<String>>;
48
49 fn find_pr_for_branch(&self, dir: &Path, branch: &str) -> Result<Option<OpenPr>>;
51
52 fn create_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
56
57 fn edit_pr(&self, dir: &Path, args: &[String]) -> Result<String>;
60
61 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#[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 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
174fn 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}