1use std::process::Command;
7
8use crate::error::{GwError, Result};
9
10use super::parser::parse_pr_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,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 delete_remote_branch(&self, branch: &str) -> Result<()> {
151 let output = self
152 .executor
153 .execute("git", &["push", "origin", "--delete", branch])?;
154
155 if output.success {
156 Ok(())
157 } else if output.stderr.contains("remote ref does not exist") {
158 Ok(())
160 } else {
161 Err(GwError::GitCommandFailed(format!(
162 "Failed to delete remote branch: {}",
163 output.stderr.trim()
164 )))
165 }
166 }
167
168 pub fn add_pr_comment(&self, pr_number: u64, comment: &str) -> Result<()> {
170 let output = self.executor.execute(
171 "gh",
172 &["pr", "comment", &pr_number.to_string(), "-b", comment],
173 )?;
174
175 if output.success {
176 Ok(())
177 } else {
178 Err(GwError::GitCommandFailed(format!(
179 "Failed to add PR comment: {}",
180 output.stderr.trim()
181 )))
182 }
183 }
184
185 pub fn update_pr_base(&self, pr_number: u64, new_base: &str) -> Result<()> {
187 let output = self.executor.execute(
188 "gh",
189 &["pr", "edit", &pr_number.to_string(), "--base", new_base],
190 )?;
191
192 if output.success {
193 Ok(())
194 } else {
195 Err(GwError::GitCommandFailed(format!(
196 "Failed to update PR base: {}",
197 output.stderr.trim()
198 )))
199 }
200 }
201
202 fn handle_pr_view_error(&self, stderr: &str) -> Result<Option<PrInfo>> {
204 if stderr.contains("no pull requests found") || stderr.contains("Could not resolve") {
205 return Ok(None);
206 }
207 if stderr.contains("auth login") {
208 return Err(GwError::Other(
209 "GitHub CLI not authenticated. Run: gh auth login".to_string(),
210 ));
211 }
212 Err(GwError::GitCommandFailed(format!(
213 "gh pr view failed: {}",
214 stderr.trim()
215 )))
216 }
217
218 fn convert_raw_to_pr_info(&self, raw: RawPrData) -> Result<PrInfo> {
220 let state = match raw.state.as_str() {
221 "OPEN" => PrState::Open,
222 "MERGED" => {
223 let method = self.detect_merge_method(&raw.merge_commit);
224 PrState::Merged {
225 method,
226 merge_commit: raw.merge_commit,
227 }
228 }
229 "CLOSED" => PrState::Closed,
230 _ => PrState::Closed,
231 };
232
233 Ok(PrInfo {
234 number: raw.number,
235 title: raw.title,
236 url: raw.url,
237 state,
238 base_branch: raw.base_branch,
239 })
240 }
241
242 fn detect_merge_method(&self, merge_commit: &Option<String>) -> MergeMethod {
250 let Some(sha) = merge_commit else {
251 return MergeMethod::Rebase;
252 };
253
254 let Ok(output) = self.executor.execute("git", &["cat-file", "-p", sha]) else {
255 return MergeMethod::Squash;
256 };
257
258 if !output.success {
259 return MergeMethod::Squash;
260 }
261
262 let parent_count = output
263 .stdout
264 .lines()
265 .filter(|l| l.starts_with("parent "))
266 .count();
267
268 match parent_count {
269 2 => MergeMethod::Merge,
270 1 => MergeMethod::Squash,
271 _ => MergeMethod::Squash,
272 }
273 }
274}
275
276pub fn is_gh_available() -> bool {
280 GitHubClient::new().is_available()
281}
282
283pub fn is_gh_authenticated() -> bool {
285 GitHubClient::new().is_authenticated()
286}
287
288pub fn get_pr_for_branch(branch: &str) -> Result<Option<PrInfo>> {
290 GitHubClient::new().get_pr_for_branch(branch)
291}
292
293pub fn delete_remote_branch(branch: &str) -> Result<()> {
295 GitHubClient::new().delete_remote_branch(branch)
296}
297
298pub fn add_pr_comment(pr_number: u64, comment: &str) -> Result<()> {
300 GitHubClient::new().add_pr_comment(pr_number, comment)
301}
302
303pub fn update_pr_base(pr_number: u64, new_base: &str) -> Result<()> {
305 GitHubClient::new().update_pr_base(pr_number, new_base)
306}