Skip to main content

coda_core/
gh.rs

1//! GitHub CLI operations abstraction.
2//!
3//! Defines the [`GhOps`] trait for GitHub CLI interactions and provides
4//! [`DefaultGhOps`], the production implementation that shells out to `gh`.
5//! This abstraction enables unit-testing modules that query PR state without
6//! requiring a real GitHub repository.
7
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11use tracing::debug;
12
13use crate::CoreError;
14
15/// PR status as returned by the GitHub CLI.
16#[derive(Debug, Clone, Deserialize)]
17pub struct PrStatus {
18    /// PR state string (e.g., `"OPEN"`, `"MERGED"`, `"CLOSED"`).
19    pub state: String,
20
21    /// PR number in the repository.
22    #[serde(default)]
23    pub number: u32,
24
25    /// PR URL (only populated by some queries).
26    #[serde(default)]
27    pub url: Option<String>,
28}
29
30/// Abstraction over GitHub CLI (`gh`) operations.
31///
32/// Implementations must be `Send + Sync` so they can be shared across async
33/// tasks.
34pub trait GhOps: Send + Sync {
35    /// Queries the state of a PR by its number.
36    ///
37    /// Returns `Ok(None)` when the `gh` command fails (e.g., not authenticated
38    /// or PR doesn't exist).
39    ///
40    /// # Errors
41    ///
42    /// Returns `CoreError::GitError` only on JSON parse failures; command
43    /// failures are mapped to `Ok(None)`.
44    fn pr_view_state(&self, pr_number: u32) -> Result<Option<PrStatus>, CoreError>;
45
46    /// Discovers a PR for the given branch head across all states.
47    ///
48    /// Returns `Ok(None)` when no PR is found or the command fails.
49    ///
50    /// # Errors
51    ///
52    /// Returns `CoreError::GitError` only on JSON parse failures.
53    fn pr_list_by_branch(&self, branch: &str) -> Result<Option<PrStatus>, CoreError>;
54
55    /// Discovers the PR URL for a branch head (open PRs only).
56    ///
57    /// Used as a fallback when the agent doesn't return a PR URL.
58    /// Returns `None` when no PR is found or the command fails.
59    fn pr_url_for_branch(&self, branch: &str, cwd: &Path) -> Option<String>;
60}
61
62/// Production [`GhOps`] implementation that shells out to the `gh` binary.
63#[derive(Debug)]
64pub struct DefaultGhOps {
65    project_root: PathBuf,
66}
67
68impl DefaultGhOps {
69    /// Creates a new instance rooted at `project_root`.
70    pub fn new(project_root: PathBuf) -> Self {
71        Self { project_root }
72    }
73}
74
75impl GhOps for DefaultGhOps {
76    fn pr_view_state(&self, pr_number: u32) -> Result<Option<PrStatus>, CoreError> {
77        let number_str = pr_number.to_string();
78        let result = run_gh(
79            &self.project_root,
80            &["pr", "view", &number_str, "--json", "state,number"],
81        );
82
83        match result {
84            Ok(output) => {
85                let status: PrStatus = serde_json::from_str(output.trim()).map_err(|e| {
86                    CoreError::GitError(format!("Failed to parse gh pr view output: {e}"))
87                })?;
88                Ok(Some(status))
89            }
90            Err(e) => {
91                debug!(pr_number, error = %e, "gh pr view failed");
92                Ok(None)
93            }
94        }
95    }
96
97    fn pr_list_by_branch(&self, branch: &str) -> Result<Option<PrStatus>, CoreError> {
98        let result = run_gh(
99            &self.project_root,
100            &[
101                "pr",
102                "list",
103                "--head",
104                branch,
105                "--state",
106                "all",
107                "--json",
108                "state,number,url",
109                "--limit",
110                "1",
111            ],
112        );
113
114        match result {
115            Ok(output) => {
116                let statuses: Vec<PrStatus> = serde_json::from_str(output.trim()).map_err(|e| {
117                    CoreError::GitError(format!("Failed to parse gh pr list output: {e}"))
118                })?;
119                Ok(statuses.into_iter().next())
120            }
121            Err(e) => {
122                debug!(branch, error = %e, "gh pr list failed");
123                Ok(None)
124            }
125        }
126    }
127
128    fn pr_url_for_branch(&self, branch: &str, cwd: &Path) -> Option<String> {
129        let result = run_gh(
130            cwd,
131            &[
132                "pr", "list", "--head", branch, "--json", "url", "--limit", "1",
133            ],
134        );
135
136        match result {
137            Ok(output) => {
138                let trimmed = output.trim();
139                if trimmed.is_empty() || trimmed == "[]" {
140                    debug!(branch, "No PR found via gh pr list");
141                    return None;
142                }
143                if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
144                    arr.first()
145                        .and_then(|v| v.get("url"))
146                        .and_then(|v| v.as_str())
147                        .map(String::from)
148                } else {
149                    crate::parser::extract_pr_url(trimmed)
150                }
151            }
152            Err(e) => {
153                tracing::warn!(error = %e, "gh pr list failed");
154                None
155            }
156        }
157    }
158}
159
160/// Runs a `gh` command and returns its stdout.
161fn run_gh(cwd: &Path, args: &[&str]) -> Result<String, CoreError> {
162    debug!(cwd = %cwd.display(), args = ?args, "gh");
163    let output = std::process::Command::new("gh")
164        .args(args)
165        .current_dir(cwd)
166        .output()
167        .map_err(CoreError::IoError)?;
168
169    if !output.status.success() {
170        let stderr = String::from_utf8_lossy(&output.stderr);
171        return Err(CoreError::GitError(format!(
172            "gh {} failed: {stderr}",
173            args.join(" "),
174        )));
175    }
176
177    Ok(String::from_utf8_lossy(&output.stdout).to_string())
178}