1use std::process::Command;
7
8use crate::error::{GwError, Result};
9
10use super::parser::{parse_pr_json, parse_pr_list_json};
11use super::types::{MergeMethod, PrInfo, PrState, RawPrData};
12
13#[derive(Debug, Clone)]
15pub struct CommandOutput {
16 pub success: bool,
17 pub stdout: String,
18 pub stderr: String,
19}
20
21impl CommandOutput {
22 pub fn success(stdout: impl Into<String>) -> Self {
23 Self {
24 success: true,
25 stdout: stdout.into(),
26 stderr: String::new(),
27 }
28 }
29
30 pub fn failure(stderr: impl Into<String>) -> Self {
31 Self {
32 success: false,
33 stdout: String::new(),
34 stderr: stderr.into(),
35 }
36 }
37}
38
39pub trait CommandExecutor: Send + Sync {
43 fn execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput>;
45
46 fn execute_in_dir(&self, program: &str, args: &[&str], dir: &str) -> Result<CommandOutput>;
48}
49
50#[derive(Debug, Default)]
52pub struct RealCommandExecutor;
53
54impl CommandExecutor for RealCommandExecutor {
55 fn execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput> {
56 let output = Command::new(program)
57 .args(args)
58 .output()
59 .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute {program}: {e}")))?;
60
61 Ok(CommandOutput {
62 success: output.status.success(),
63 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
64 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
65 })
66 }
67
68 fn execute_in_dir(&self, program: &str, args: &[&str], dir: &str) -> Result<CommandOutput> {
69 let output = Command::new(program)
70 .args(args)
71 .current_dir(dir)
72 .output()
73 .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute {program}: {e}")))?;
74
75 Ok(CommandOutput {
76 success: output.status.success(),
77 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
78 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
79 })
80 }
81}
82
83pub struct GitHubClient<E: CommandExecutor = RealCommandExecutor> {
85 executor: E,
86}
87
88impl Default for GitHubClient<RealCommandExecutor> {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl GitHubClient<RealCommandExecutor> {
95 pub fn new() -> Self {
97 Self {
98 executor: RealCommandExecutor,
99 }
100 }
101}
102
103impl<E: CommandExecutor> GitHubClient<E> {
104 pub fn with_executor(executor: E) -> Self {
106 Self { executor }
107 }
108
109 pub fn is_available(&self) -> bool {
111 self.executor
112 .execute("gh", &["--version"])
113 .map(|o| o.success)
114 .unwrap_or(false)
115 }
116
117 pub fn is_authenticated(&self) -> bool {
119 self.executor
120 .execute("gh", &["auth", "status"])
121 .map(|o| o.success)
122 .unwrap_or(false)
123 }
124
125 pub fn get_pr_for_branch(&self, branch: &str) -> Result<Option<PrInfo>> {
129 let output = self.executor.execute(
130 "gh",
131 &[
132 "pr",
133 "view",
134 branch,
135 "--json",
136 "number,title,url,state,baseRefName,headRefName,mergeCommit,mergedAt",
137 ],
138 )?;
139
140 if !output.success {
141 return self.handle_pr_view_error(&output.stderr);
142 }
143
144 let raw = parse_pr_json(&output.stdout)?;
145 let pr_info = self.convert_raw_to_pr_info(raw)?;
146 Ok(Some(pr_info))
147 }
148
149 pub fn open_prs_with_base(&self, base: &str) -> Result<Vec<PrInfo>> {
154 let output = self.executor.execute(
155 "gh",
156 &[
157 "pr",
158 "list",
159 "--base",
160 base,
161 "--state",
162 "open",
163 "--json",
164 "number,title,url,state,baseRefName,headRefName,mergeCommit",
165 ],
166 )?;
167
168 if !output.success {
169 return Err(GwError::GitCommandFailed(format!(
170 "gh pr list failed: {}",
171 output.stderr.trim()
172 )));
173 }
174
175 parse_pr_list_json(&output.stdout)?
176 .into_iter()
177 .map(|raw| self.convert_raw_to_pr_info(raw))
178 .collect()
179 }
180
181 pub fn delete_remote_branch(&self, branch: &str) -> Result<()> {
183 let output = self
184 .executor
185 .execute("git", &["push", "origin", "--delete", branch])?;
186
187 if output.success {
188 Ok(())
189 } else if output.stderr.contains("remote ref does not exist") {
190 Ok(())
192 } else {
193 Err(GwError::GitCommandFailed(format!(
194 "Failed to delete remote branch: {}",
195 output.stderr.trim()
196 )))
197 }
198 }
199
200 pub fn add_pr_comment(&self, pr_number: u64, comment: &str) -> Result<()> {
202 let output = self.executor.execute(
203 "gh",
204 &["pr", "comment", &pr_number.to_string(), "-b", comment],
205 )?;
206
207 if output.success {
208 Ok(())
209 } else {
210 Err(GwError::GitCommandFailed(format!(
211 "Failed to add PR comment: {}",
212 output.stderr.trim()
213 )))
214 }
215 }
216
217 pub fn update_pr_base(&self, pr_number: u64, new_base: &str) -> Result<()> {
219 let output = self.executor.execute(
220 "gh",
221 &["pr", "edit", &pr_number.to_string(), "--base", new_base],
222 )?;
223
224 if output.success {
225 Ok(())
226 } else {
227 Err(GwError::GitCommandFailed(format!(
228 "Failed to update PR base: {}",
229 output.stderr.trim()
230 )))
231 }
232 }
233
234 fn handle_pr_view_error(&self, stderr: &str) -> Result<Option<PrInfo>> {
236 if stderr.contains("no pull requests found") || stderr.contains("Could not resolve") {
237 return Ok(None);
238 }
239 if stderr.contains("auth login") {
240 return Err(GwError::Other(
241 "GitHub CLI not authenticated. Run: gh auth login".to_string(),
242 ));
243 }
244 Err(GwError::GitCommandFailed(format!(
245 "gh pr view failed: {}",
246 stderr.trim()
247 )))
248 }
249
250 fn convert_raw_to_pr_info(&self, raw: RawPrData) -> Result<PrInfo> {
252 let state = match raw.state.as_str() {
253 "OPEN" => PrState::Open,
254 "MERGED" => {
255 let method = self.detect_merge_method(&raw.merge_commit);
256 PrState::Merged {
257 method,
258 merge_commit: raw.merge_commit,
259 }
260 }
261 "CLOSED" => PrState::Closed,
262 _ => PrState::Closed,
263 };
264
265 Ok(PrInfo {
266 number: raw.number,
267 title: raw.title,
268 url: raw.url,
269 state,
270 base_branch: raw.base_branch,
271 head_branch: raw.head_branch,
272 })
273 }
274
275 fn detect_merge_method(&self, merge_commit: &Option<String>) -> MergeMethod {
283 let Some(sha) = merge_commit else {
284 return MergeMethod::Rebase;
285 };
286
287 let Ok(output) = self.executor.execute("git", &["cat-file", "-p", sha]) else {
288 return MergeMethod::Squash;
289 };
290
291 if !output.success {
292 return MergeMethod::Squash;
293 }
294
295 let parent_count = output
296 .stdout
297 .lines()
298 .filter(|l| l.starts_with("parent "))
299 .count();
300
301 match parent_count {
302 2 => MergeMethod::Merge,
303 1 => MergeMethod::Squash,
304 _ => MergeMethod::Squash,
305 }
306 }
307}
308
309pub fn is_gh_available() -> bool {
313 GitHubClient::new().is_available()
314}
315
316pub fn is_gh_authenticated() -> bool {
318 GitHubClient::new().is_authenticated()
319}
320
321pub fn get_pr_for_branch(branch: &str) -> Result<Option<PrInfo>> {
323 GitHubClient::new().get_pr_for_branch(branch)
324}
325
326pub fn delete_remote_branch(branch: &str) -> Result<()> {
328 GitHubClient::new().delete_remote_branch(branch)
329}
330
331pub fn open_prs_with_base(base: &str) -> Result<Vec<PrInfo>> {
333 GitHubClient::new().open_prs_with_base(base)
334}
335
336pub fn add_pr_comment(pr_number: u64, comment: &str) -> Result<()> {
338 GitHubClient::new().add_pr_comment(pr_number, comment)
339}
340
341pub fn update_pr_base(pr_number: u64, new_base: &str) -> Result<()> {
343 GitHubClient::new().update_pr_base(pr_number, new_base)
344}