1use std::path::Path;
2
3use anyhow::{Context, Result};
4use tracing::warn;
5
6use super::{GhClient, PrState};
7use crate::process::CommandRunner;
8
9impl<R: CommandRunner> GhClient<R> {
10 pub async fn create_draft_pr(&self, title: &str, branch: &str, body: &str) -> Result<u32> {
12 self.create_draft_pr_in(title, branch, body, &self.repo_dir).await
13 }
14
15 pub async fn create_draft_pr_in(
19 &self,
20 title: &str,
21 branch: &str,
22 body: &str,
23 repo_dir: &Path,
24 ) -> Result<u32> {
25 let output = self
26 .runner
27 .run_gh(
28 &Self::s(&[
29 "pr", "create", "--title", title, "--body", body, "--head", branch, "--draft",
30 ]),
31 repo_dir,
32 )
33 .await
34 .context("creating draft PR")?;
35 Self::check_output(&output, "create draft PR")?;
36
37 let url = output.stdout.trim();
39 let pr_number = url
40 .rsplit('/')
41 .next()
42 .and_then(|s| s.parse::<u32>().ok())
43 .context("parsing PR number from gh output")?;
44
45 Ok(pr_number)
46 }
47
48 pub async fn comment_on_pr(&self, pr_number: u32, body: &str) -> Result<()> {
50 self.comment_on_pr_in(pr_number, body, &self.repo_dir).await
51 }
52
53 pub async fn comment_on_pr_in(
55 &self,
56 pr_number: u32,
57 body: &str,
58 repo_dir: &Path,
59 ) -> Result<()> {
60 let output = self
61 .runner
62 .run_gh(&Self::s(&["pr", "comment", &pr_number.to_string(), "--body", body]), repo_dir)
63 .await
64 .context("commenting on PR")?;
65 Self::check_output(&output, "comment on PR")?;
66 Ok(())
67 }
68
69 pub async fn edit_pr(&self, pr_number: u32, title: &str, body: &str) -> Result<()> {
71 self.edit_pr_in(pr_number, title, body, &self.repo_dir).await
72 }
73
74 pub async fn edit_pr_in(
76 &self,
77 pr_number: u32,
78 title: &str,
79 body: &str,
80 repo_dir: &Path,
81 ) -> Result<()> {
82 let output = self
83 .runner
84 .run_gh(
85 &Self::s(&["pr", "edit", &pr_number.to_string(), "--title", title, "--body", body]),
86 repo_dir,
87 )
88 .await
89 .context("editing PR")?;
90 Self::check_output(&output, "edit PR")?;
91 Ok(())
92 }
93
94 pub async fn mark_pr_ready(&self, pr_number: u32) -> Result<()> {
96 self.mark_pr_ready_in(pr_number, &self.repo_dir).await
97 }
98
99 pub async fn mark_pr_ready_in(&self, pr_number: u32, repo_dir: &Path) -> Result<()> {
101 let output = self
102 .runner
103 .run_gh(&Self::s(&["pr", "ready", &pr_number.to_string()]), repo_dir)
104 .await
105 .context("marking PR ready")?;
106 Self::check_output(&output, "mark PR ready")?;
107 Ok(())
108 }
109
110 pub async fn get_pr_state(&self, pr_number: u32) -> Result<PrState> {
112 self.get_pr_state_in(pr_number, &self.repo_dir).await
113 }
114
115 pub async fn get_pr_state_in(&self, pr_number: u32, repo_dir: &Path) -> Result<PrState> {
117 let output = self
118 .runner
119 .run_gh(&Self::s(&["pr", "view", &pr_number.to_string(), "--json", "state"]), repo_dir)
120 .await
121 .context("checking PR state")?;
122 Self::check_output(&output, "check PR state")?;
123
124 let parsed: serde_json::Value =
125 serde_json::from_str(output.stdout.trim()).context("parsing PR state JSON")?;
126 let state_str = parsed["state"].as_str().unwrap_or("UNKNOWN");
127
128 Ok(match state_str {
129 "MERGED" => PrState::Merged,
130 "CLOSED" => PrState::Closed,
131 "OPEN" => PrState::Open,
132 other => {
133 warn!(pr = pr_number, state = other, "unexpected PR state, treating as Open");
134 PrState::Open
135 }
136 })
137 }
138
139 pub async fn merge_pr(&self, pr_number: u32) -> Result<()> {
141 self.merge_pr_in(pr_number, &self.repo_dir).await
142 }
143
144 pub async fn merge_pr_in(&self, pr_number: u32, repo_dir: &Path) -> Result<()> {
146 let output = self
147 .runner
148 .run_gh(
149 &Self::s(&["pr", "merge", &pr_number.to_string(), "--squash", "--delete-branch"]),
150 repo_dir,
151 )
152 .await
153 .context("merging PR")?;
154 Self::check_output(&output, "merge PR")?;
155 Ok(())
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use std::path::Path;
162
163 use crate::{
164 github::GhClient,
165 process::{CommandOutput, MockCommandRunner},
166 };
167
168 #[tokio::test]
169 async fn create_draft_pr_returns_number() {
170 let mut mock = MockCommandRunner::new();
171 mock.expect_run_gh().returning(|_, _| {
172 Box::pin(async {
173 Ok(CommandOutput {
174 stdout: "https://github.com/user/repo/pull/99\n".to_string(),
175 stderr: String::new(),
176 success: true,
177 })
178 })
179 });
180
181 let client = GhClient::new(mock, Path::new("/tmp"));
182 let pr_number = client.create_draft_pr("title", "branch", "body").await.unwrap();
183 assert_eq!(pr_number, 99);
184 }
185
186 #[tokio::test]
187 async fn edit_pr_succeeds() {
188 let mut mock = MockCommandRunner::new();
189 mock.expect_run_gh().returning(|_, _| {
190 Box::pin(async {
191 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
192 })
193 });
194
195 let client = GhClient::new(mock, Path::new("/tmp"));
196 let result = client.edit_pr(42, "new title", "new body").await;
197 assert!(result.is_ok());
198 }
199
200 #[tokio::test]
201 async fn edit_pr_in_uses_given_dir() {
202 let mut mock = MockCommandRunner::new();
203 mock.expect_run_gh().returning(|_, dir| {
204 assert_eq!(dir, Path::new("/repos/backend"));
205 Box::pin(async {
206 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
207 })
208 });
209
210 let client = GhClient::new(mock, Path::new("/repos/god"));
211 let result = client.edit_pr_in(42, "title", "body", Path::new("/repos/backend")).await;
212 assert!(result.is_ok());
213 }
214
215 #[tokio::test]
216 async fn edit_pr_failure_propagates() {
217 let mut mock = MockCommandRunner::new();
218 mock.expect_run_gh().returning(|_, _| {
219 Box::pin(async {
220 Ok(CommandOutput {
221 stdout: String::new(),
222 stderr: "not found".to_string(),
223 success: false,
224 })
225 })
226 });
227
228 let client = GhClient::new(mock, Path::new("/tmp"));
229 let result = client.edit_pr(42, "title", "body").await;
230 assert!(result.is_err());
231 assert!(result.unwrap_err().to_string().contains("not found"));
232 }
233
234 #[tokio::test]
235 async fn comment_on_pr_succeeds() {
236 let mut mock = MockCommandRunner::new();
237 mock.expect_run_gh().returning(|_, _| {
238 Box::pin(async {
239 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
240 })
241 });
242
243 let client = GhClient::new(mock, Path::new("/tmp"));
244 let result = client.comment_on_pr(42, "looks good").await;
245 assert!(result.is_ok());
246 }
247
248 #[tokio::test]
249 async fn mark_pr_ready_succeeds() {
250 let mut mock = MockCommandRunner::new();
251 mock.expect_run_gh().returning(|_, _| {
252 Box::pin(async {
253 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
254 })
255 });
256
257 let client = GhClient::new(mock, Path::new("/tmp"));
258 let result = client.mark_pr_ready(42).await;
259 assert!(result.is_ok());
260 }
261
262 #[tokio::test]
263 async fn merge_pr_succeeds() {
264 let mut mock = MockCommandRunner::new();
265 mock.expect_run_gh().returning(|_, _| {
266 Box::pin(async {
267 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
268 })
269 });
270
271 let client = GhClient::new(mock, Path::new("/tmp"));
272 let result = client.merge_pr(42).await;
273 assert!(result.is_ok());
274 }
275
276 #[tokio::test]
277 async fn get_pr_state_merged() {
278 let mut mock = MockCommandRunner::new();
279 mock.expect_run_gh().returning(|_, _| {
280 Box::pin(async {
281 Ok(CommandOutput {
282 stdout: r#"{"state":"MERGED"}"#.to_string(),
283 stderr: String::new(),
284 success: true,
285 })
286 })
287 });
288
289 let client = GhClient::new(mock, Path::new("/tmp"));
290 let state = client.get_pr_state(42).await.unwrap();
291 assert_eq!(state, crate::github::PrState::Merged);
292 }
293
294 #[tokio::test]
295 async fn get_pr_state_open() {
296 let mut mock = MockCommandRunner::new();
297 mock.expect_run_gh().returning(|_, _| {
298 Box::pin(async {
299 Ok(CommandOutput {
300 stdout: r#"{"state":"OPEN"}"#.to_string(),
301 stderr: String::new(),
302 success: true,
303 })
304 })
305 });
306
307 let client = GhClient::new(mock, Path::new("/tmp"));
308 let state = client.get_pr_state(42).await.unwrap();
309 assert_eq!(state, crate::github::PrState::Open);
310 }
311
312 #[tokio::test]
313 async fn get_pr_state_closed() {
314 let mut mock = MockCommandRunner::new();
315 mock.expect_run_gh().returning(|_, _| {
316 Box::pin(async {
317 Ok(CommandOutput {
318 stdout: r#"{"state":"CLOSED"}"#.to_string(),
319 stderr: String::new(),
320 success: true,
321 })
322 })
323 });
324
325 let client = GhClient::new(mock, Path::new("/tmp"));
326 let state = client.get_pr_state(42).await.unwrap();
327 assert_eq!(state, crate::github::PrState::Closed);
328 }
329
330 #[tokio::test]
331 async fn get_pr_state_unknown_defaults_to_open() {
332 let mut mock = MockCommandRunner::new();
333 mock.expect_run_gh().returning(|_, _| {
334 Box::pin(async {
335 Ok(CommandOutput {
336 stdout: r#"{"state":"DRAFT"}"#.to_string(),
337 stderr: String::new(),
338 success: true,
339 })
340 })
341 });
342
343 let client = GhClient::new(mock, Path::new("/tmp"));
344 let state = client.get_pr_state(42).await.unwrap();
345 assert_eq!(state, crate::github::PrState::Open);
346 }
347
348 #[tokio::test]
349 async fn merge_pr_failure_propagates() {
350 let mut mock = MockCommandRunner::new();
351 mock.expect_run_gh().returning(|_, _| {
352 Box::pin(async {
353 Ok(CommandOutput {
354 stdout: String::new(),
355 stderr: "merge conflict".to_string(),
356 success: false,
357 })
358 })
359 });
360
361 let client = GhClient::new(mock, Path::new("/tmp"));
362 let result = client.merge_pr(42).await;
363 assert!(result.is_err());
364 assert!(result.unwrap_err().to_string().contains("merge conflict"));
365 }
366
367 #[tokio::test]
368 async fn comment_on_pr_in_uses_given_dir() {
369 let mut mock = MockCommandRunner::new();
370 mock.expect_run_gh().returning(|_, dir| {
371 assert_eq!(dir, Path::new("/repos/backend"));
372 Box::pin(async {
373 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
374 })
375 });
376
377 let client = GhClient::new(mock, Path::new("/repos/god"));
378 let result = client.comment_on_pr_in(42, "comment", Path::new("/repos/backend")).await;
379 assert!(result.is_ok());
380 }
381
382 #[tokio::test]
383 async fn get_pr_state_in_uses_given_dir() {
384 let mut mock = MockCommandRunner::new();
385 mock.expect_run_gh().returning(|_, dir| {
386 assert_eq!(dir, Path::new("/repos/backend"));
387 Box::pin(async {
388 Ok(CommandOutput {
389 stdout: r#"{"state":"MERGED"}"#.to_string(),
390 stderr: String::new(),
391 success: true,
392 })
393 })
394 });
395
396 let client = GhClient::new(mock, Path::new("/repos/god"));
397 let state = client.get_pr_state_in(42, Path::new("/repos/backend")).await.unwrap();
398 assert_eq!(state, crate::github::PrState::Merged);
399 }
400
401 #[tokio::test]
402 async fn mark_pr_ready_in_uses_given_dir() {
403 let mut mock = MockCommandRunner::new();
404 mock.expect_run_gh().returning(|_, dir| {
405 assert_eq!(dir, Path::new("/repos/backend"));
406 Box::pin(async {
407 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
408 })
409 });
410
411 let client = GhClient::new(mock, Path::new("/repos/god"));
412 let result = client.mark_pr_ready_in(42, Path::new("/repos/backend")).await;
413 assert!(result.is_ok());
414 }
415
416 #[tokio::test]
417 async fn merge_pr_in_uses_given_dir() {
418 let mut mock = MockCommandRunner::new();
419 mock.expect_run_gh().returning(|_, dir| {
420 assert_eq!(dir, Path::new("/repos/backend"));
421 Box::pin(async {
422 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
423 })
424 });
425
426 let client = GhClient::new(mock, Path::new("/repos/god"));
427 let result = client.merge_pr_in(42, Path::new("/repos/backend")).await;
428 assert!(result.is_ok());
429 }
430}