1use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11use tracing::debug;
12
13use crate::CoreError;
14
15#[derive(Debug, Clone, Deserialize)]
17pub struct PrStatus {
18 pub state: String,
20
21 #[serde(default)]
23 pub number: u32,
24
25 #[serde(default)]
27 pub url: Option<String>,
28}
29
30pub trait GhOps: Send + Sync {
35 fn pr_view_state(&self, pr_number: u32) -> Result<Option<PrStatus>, CoreError>;
45
46 fn pr_list_by_branch(&self, branch: &str) -> Result<Option<PrStatus>, CoreError>;
54
55 fn pr_url_for_branch(&self, branch: &str, cwd: &Path) -> Option<String>;
60}
61
62#[derive(Debug)]
64pub struct DefaultGhOps {
65 project_root: PathBuf,
66}
67
68impl DefaultGhOps {
69 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
160fn 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}