Skip to main content

git_workflow/github/
client.rs

1//! GitHub client abstraction
2//!
3//! Provides a trait-based interface for GitHub operations,
4//! allowing for dependency injection and testing.
5
6use 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/// Output from a command execution
14#[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
39/// Trait for executing shell commands
40///
41/// This abstraction allows mocking command execution in tests.
42pub trait CommandExecutor: Send + Sync {
43    /// Execute a command and return its output
44    fn execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput>;
45
46    /// Execute a command in a specific directory
47    fn execute_in_dir(&self, program: &str, args: &[&str], dir: &str) -> Result<CommandOutput>;
48}
49
50/// Real command executor that runs actual shell commands
51#[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
83/// GitHub client for interacting with GitHub via gh CLI
84pub 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    /// Create a new GitHubClient with the real command executor
96    pub fn new() -> Self {
97        Self {
98            executor: RealCommandExecutor,
99        }
100    }
101}
102
103impl<E: CommandExecutor> GitHubClient<E> {
104    /// Create a GitHubClient with a custom command executor (for testing)
105    pub fn with_executor(executor: E) -> Self {
106        Self { executor }
107    }
108
109    /// Check if `gh` CLI is available
110    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    /// Check if `gh` is authenticated
118    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    /// Get PR information for a branch
126    ///
127    /// Returns `None` if no PR exists for this branch.
128    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    /// Delete a remote branch
150    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            // Branch already deleted is not an error
159            Ok(())
160        } else {
161            Err(GwError::GitCommandFailed(format!(
162                "Failed to delete remote branch: {}",
163                output.stderr.trim()
164            )))
165        }
166    }
167
168    /// Add a comment to a PR
169    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    /// Update PR base branch
186    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    /// Handle error from `gh pr view`
203    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    /// Convert raw PR data to PrInfo, detecting merge method
219    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    /// Detect merge method from merge commit
243    ///
244    /// Note: GitHub API doesn't directly expose merge method for merged PRs.
245    /// We infer it by checking commit parent count:
246    /// - 2 parents -> regular merge
247    /// - 1 parent -> squash or rebase
248    /// - No merge commit -> rebase
249    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
276// Convenience functions using the default client (backward compatibility)
277
278/// Check if `gh` CLI is available
279pub fn is_gh_available() -> bool {
280    GitHubClient::new().is_available()
281}
282
283/// Check if `gh` is authenticated
284pub fn is_gh_authenticated() -> bool {
285    GitHubClient::new().is_authenticated()
286}
287
288/// Get PR information for a branch
289pub fn get_pr_for_branch(branch: &str) -> Result<Option<PrInfo>> {
290    GitHubClient::new().get_pr_for_branch(branch)
291}
292
293/// Delete a remote branch
294pub fn delete_remote_branch(branch: &str) -> Result<()> {
295    GitHubClient::new().delete_remote_branch(branch)
296}
297
298/// Add a comment to a PR
299pub fn add_pr_comment(pr_number: u64, comment: &str) -> Result<()> {
300    GitHubClient::new().add_pr_comment(pr_number, comment)
301}
302
303/// Update PR base branch
304pub fn update_pr_base(pr_number: u64, new_base: &str) -> Result<()> {
305    GitHubClient::new().update_pr_base(pr_number, new_base)
306}