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, parse_pr_list_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,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    /// List the open PRs that target `base` as their base branch.
150    ///
151    /// Used before deleting a branch so we don't delete the base of an open
152    /// stacked PR (which GitHub would close). Returns an empty vec when none.
153    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    /// Delete a remote branch
182    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            // Branch already deleted is not an error
191            Ok(())
192        } else {
193            Err(GwError::GitCommandFailed(format!(
194                "Failed to delete remote branch: {}",
195                output.stderr.trim()
196            )))
197        }
198    }
199
200    /// Add a comment to a PR
201    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    /// Update PR base branch
218    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    /// Handle error from `gh pr view`
235    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    /// Convert raw PR data to PrInfo, detecting merge method
251    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    /// Detect merge method from merge commit
276    ///
277    /// Note: GitHub API doesn't directly expose merge method for merged PRs.
278    /// We infer it by checking commit parent count:
279    /// - 2 parents -> regular merge
280    /// - 1 parent -> squash or rebase
281    /// - No merge commit -> rebase
282    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
309// Convenience functions using the default client (backward compatibility)
310
311/// Check if `gh` CLI is available
312pub fn is_gh_available() -> bool {
313    GitHubClient::new().is_available()
314}
315
316/// Check if `gh` is authenticated
317pub fn is_gh_authenticated() -> bool {
318    GitHubClient::new().is_authenticated()
319}
320
321/// Get PR information for a branch
322pub fn get_pr_for_branch(branch: &str) -> Result<Option<PrInfo>> {
323    GitHubClient::new().get_pr_for_branch(branch)
324}
325
326/// Delete a remote branch
327pub fn delete_remote_branch(branch: &str) -> Result<()> {
328    GitHubClient::new().delete_remote_branch(branch)
329}
330
331/// List the open PRs that target `base` as their base branch.
332pub fn open_prs_with_base(base: &str) -> Result<Vec<PrInfo>> {
333    GitHubClient::new().open_prs_with_base(base)
334}
335
336/// Add a comment to a PR
337pub fn add_pr_comment(pr_number: u64, comment: &str) -> Result<()> {
338    GitHubClient::new().add_pr_comment(pr_number, comment)
339}
340
341/// Update PR base branch
342pub fn update_pr_base(pr_number: u64, new_base: &str) -> Result<()> {
343    GitHubClient::new().update_pr_base(pr_number, new_base)
344}