1use std::path::Path;
2
3use anyhow::{Context, Result};
4use tracing::warn;
5
6use super::{GhClient, PrState};
7use crate::{config::MergeStrategy, 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, strategy: &MergeStrategy) -> Result<()> {
141 self.merge_pr_in(pr_number, strategy, &self.repo_dir).await
142 }
143
144 pub async fn merge_pr_in(
146 &self,
147 pr_number: u32,
148 strategy: &MergeStrategy,
149 repo_dir: &Path,
150 ) -> Result<()> {
151 let output = self
152 .runner
153 .run_gh(
154 &Self::s(&["pr", "merge", &pr_number.to_string(), strategy.gh_flag()]),
155 repo_dir,
156 )
157 .await
158 .context("merging PR")?;
159 Self::check_output(&output, "merge PR")?;
160 Ok(())
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use std::path::Path;
167
168 use crate::{
169 config::MergeStrategy,
170 github::GhClient,
171 process::{CommandOutput, MockCommandRunner},
172 };
173
174 #[tokio::test]
175 async fn create_draft_pr_returns_number() {
176 let mut mock = MockCommandRunner::new();
177 mock.expect_run_gh().returning(|_, _| {
178 Box::pin(async {
179 Ok(CommandOutput {
180 stdout: "https://github.com/user/repo/pull/99\n".to_string(),
181 stderr: String::new(),
182 success: true,
183 })
184 })
185 });
186
187 let client = GhClient::new(mock, Path::new("/tmp"));
188 let pr_number = client.create_draft_pr("title", "branch", "body").await.unwrap();
189 assert_eq!(pr_number, 99);
190 }
191
192 #[tokio::test]
193 async fn edit_pr_succeeds() {
194 let mut mock = MockCommandRunner::new();
195 mock.expect_run_gh().returning(|_, _| {
196 Box::pin(async {
197 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
198 })
199 });
200
201 let client = GhClient::new(mock, Path::new("/tmp"));
202 let result = client.edit_pr(42, "new title", "new body").await;
203 assert!(result.is_ok());
204 }
205
206 #[tokio::test]
207 async fn edit_pr_in_uses_given_dir() {
208 let mut mock = MockCommandRunner::new();
209 mock.expect_run_gh().returning(|_, dir| {
210 assert_eq!(dir, Path::new("/repos/backend"));
211 Box::pin(async {
212 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
213 })
214 });
215
216 let client = GhClient::new(mock, Path::new("/repos/god"));
217 let result = client.edit_pr_in(42, "title", "body", Path::new("/repos/backend")).await;
218 assert!(result.is_ok());
219 }
220
221 #[tokio::test]
222 async fn edit_pr_failure_propagates() {
223 let mut mock = MockCommandRunner::new();
224 mock.expect_run_gh().returning(|_, _| {
225 Box::pin(async {
226 Ok(CommandOutput {
227 stdout: String::new(),
228 stderr: "not found".to_string(),
229 success: false,
230 })
231 })
232 });
233
234 let client = GhClient::new(mock, Path::new("/tmp"));
235 let result = client.edit_pr(42, "title", "body").await;
236 assert!(result.is_err());
237 assert!(result.unwrap_err().to_string().contains("not found"));
238 }
239
240 #[tokio::test]
241 async fn comment_on_pr_succeeds() {
242 let mut mock = MockCommandRunner::new();
243 mock.expect_run_gh().returning(|_, _| {
244 Box::pin(async {
245 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
246 })
247 });
248
249 let client = GhClient::new(mock, Path::new("/tmp"));
250 let result = client.comment_on_pr(42, "looks good").await;
251 assert!(result.is_ok());
252 }
253
254 #[tokio::test]
255 async fn mark_pr_ready_succeeds() {
256 let mut mock = MockCommandRunner::new();
257 mock.expect_run_gh().returning(|_, _| {
258 Box::pin(async {
259 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
260 })
261 });
262
263 let client = GhClient::new(mock, Path::new("/tmp"));
264 let result = client.mark_pr_ready(42).await;
265 assert!(result.is_ok());
266 }
267
268 #[tokio::test]
269 async fn merge_pr_succeeds() {
270 let mut mock = MockCommandRunner::new();
271 mock.expect_run_gh().returning(|_, _| {
272 Box::pin(async {
273 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
274 })
275 });
276
277 let client = GhClient::new(mock, Path::new("/tmp"));
278 let result = client.merge_pr(42, &MergeStrategy::Squash).await;
279 assert!(result.is_ok());
280 }
281
282 #[tokio::test]
283 async fn get_pr_state_merged() {
284 let mut mock = MockCommandRunner::new();
285 mock.expect_run_gh().returning(|_, _| {
286 Box::pin(async {
287 Ok(CommandOutput {
288 stdout: r#"{"state":"MERGED"}"#.to_string(),
289 stderr: String::new(),
290 success: true,
291 })
292 })
293 });
294
295 let client = GhClient::new(mock, Path::new("/tmp"));
296 let state = client.get_pr_state(42).await.unwrap();
297 assert_eq!(state, crate::github::PrState::Merged);
298 }
299
300 #[tokio::test]
301 async fn get_pr_state_open() {
302 let mut mock = MockCommandRunner::new();
303 mock.expect_run_gh().returning(|_, _| {
304 Box::pin(async {
305 Ok(CommandOutput {
306 stdout: r#"{"state":"OPEN"}"#.to_string(),
307 stderr: String::new(),
308 success: true,
309 })
310 })
311 });
312
313 let client = GhClient::new(mock, Path::new("/tmp"));
314 let state = client.get_pr_state(42).await.unwrap();
315 assert_eq!(state, crate::github::PrState::Open);
316 }
317
318 #[tokio::test]
319 async fn get_pr_state_closed() {
320 let mut mock = MockCommandRunner::new();
321 mock.expect_run_gh().returning(|_, _| {
322 Box::pin(async {
323 Ok(CommandOutput {
324 stdout: r#"{"state":"CLOSED"}"#.to_string(),
325 stderr: String::new(),
326 success: true,
327 })
328 })
329 });
330
331 let client = GhClient::new(mock, Path::new("/tmp"));
332 let state = client.get_pr_state(42).await.unwrap();
333 assert_eq!(state, crate::github::PrState::Closed);
334 }
335
336 #[tokio::test]
337 async fn get_pr_state_unknown_defaults_to_open() {
338 let mut mock = MockCommandRunner::new();
339 mock.expect_run_gh().returning(|_, _| {
340 Box::pin(async {
341 Ok(CommandOutput {
342 stdout: r#"{"state":"DRAFT"}"#.to_string(),
343 stderr: String::new(),
344 success: true,
345 })
346 })
347 });
348
349 let client = GhClient::new(mock, Path::new("/tmp"));
350 let state = client.get_pr_state(42).await.unwrap();
351 assert_eq!(state, crate::github::PrState::Open);
352 }
353
354 #[tokio::test]
355 async fn merge_pr_failure_propagates() {
356 let mut mock = MockCommandRunner::new();
357 mock.expect_run_gh().returning(|_, _| {
358 Box::pin(async {
359 Ok(CommandOutput {
360 stdout: String::new(),
361 stderr: "merge conflict".to_string(),
362 success: false,
363 })
364 })
365 });
366
367 let client = GhClient::new(mock, Path::new("/tmp"));
368 let result = client.merge_pr(42, &MergeStrategy::Squash).await;
369 assert!(result.is_err());
370 assert!(result.unwrap_err().to_string().contains("merge conflict"));
371 }
372
373 #[tokio::test]
374 async fn comment_on_pr_in_uses_given_dir() {
375 let mut mock = MockCommandRunner::new();
376 mock.expect_run_gh().returning(|_, dir| {
377 assert_eq!(dir, Path::new("/repos/backend"));
378 Box::pin(async {
379 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
380 })
381 });
382
383 let client = GhClient::new(mock, Path::new("/repos/god"));
384 let result = client.comment_on_pr_in(42, "comment", Path::new("/repos/backend")).await;
385 assert!(result.is_ok());
386 }
387
388 #[tokio::test]
389 async fn get_pr_state_in_uses_given_dir() {
390 let mut mock = MockCommandRunner::new();
391 mock.expect_run_gh().returning(|_, dir| {
392 assert_eq!(dir, Path::new("/repos/backend"));
393 Box::pin(async {
394 Ok(CommandOutput {
395 stdout: r#"{"state":"MERGED"}"#.to_string(),
396 stderr: String::new(),
397 success: true,
398 })
399 })
400 });
401
402 let client = GhClient::new(mock, Path::new("/repos/god"));
403 let state = client.get_pr_state_in(42, Path::new("/repos/backend")).await.unwrap();
404 assert_eq!(state, crate::github::PrState::Merged);
405 }
406
407 #[tokio::test]
408 async fn mark_pr_ready_in_uses_given_dir() {
409 let mut mock = MockCommandRunner::new();
410 mock.expect_run_gh().returning(|_, dir| {
411 assert_eq!(dir, Path::new("/repos/backend"));
412 Box::pin(async {
413 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
414 })
415 });
416
417 let client = GhClient::new(mock, Path::new("/repos/god"));
418 let result = client.mark_pr_ready_in(42, Path::new("/repos/backend")).await;
419 assert!(result.is_ok());
420 }
421
422 #[tokio::test]
423 async fn merge_pr_in_uses_given_dir() {
424 let mut mock = MockCommandRunner::new();
425 mock.expect_run_gh().returning(|_, dir| {
426 assert_eq!(dir, Path::new("/repos/backend"));
427 Box::pin(async {
428 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
429 })
430 });
431
432 let client = GhClient::new(mock, Path::new("/repos/god"));
433 let result =
434 client.merge_pr_in(42, &MergeStrategy::Squash, Path::new("/repos/backend")).await;
435 assert!(result.is_ok());
436 }
437
438 #[tokio::test]
439 async fn merge_pr_passes_squash_flag() {
440 let mut mock = MockCommandRunner::new();
441 mock.expect_run_gh().returning(|args, _| {
442 assert!(args.contains(&"--squash".to_string()), "expected --squash in {args:?}");
443 Box::pin(async {
444 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
445 })
446 });
447
448 let client = GhClient::new(mock, Path::new("/tmp"));
449 client.merge_pr(42, &MergeStrategy::Squash).await.unwrap();
450 }
451
452 #[tokio::test]
453 async fn merge_pr_passes_merge_flag() {
454 let mut mock = MockCommandRunner::new();
455 mock.expect_run_gh().returning(|args, _| {
456 assert!(args.contains(&"--merge".to_string()), "expected --merge in {args:?}");
457 Box::pin(async {
458 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
459 })
460 });
461
462 let client = GhClient::new(mock, Path::new("/tmp"));
463 client.merge_pr(42, &MergeStrategy::Merge).await.unwrap();
464 }
465
466 #[tokio::test]
467 async fn merge_pr_passes_rebase_flag() {
468 let mut mock = MockCommandRunner::new();
469 mock.expect_run_gh().returning(|args, _| {
470 assert!(args.contains(&"--rebase".to_string()), "expected --rebase in {args:?}");
471 Box::pin(async {
472 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
473 })
474 });
475
476 let client = GhClient::new(mock, Path::new("/tmp"));
477 client.merge_pr(42, &MergeStrategy::Rebase).await.unwrap();
478 }
479
480 #[tokio::test]
481 async fn merge_pr_does_not_pass_delete_branch() {
482 let mut mock = MockCommandRunner::new();
483 mock.expect_run_gh().returning(|args, _| {
484 assert!(
485 !args.contains(&"--delete-branch".to_string()),
486 "merge_pr should not pass --delete-branch, got {args:?}"
487 );
488 Box::pin(async {
489 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
490 })
491 });
492
493 let client = GhClient::new(mock, Path::new("/tmp"));
494 client.merge_pr(42, &MergeStrategy::Squash).await.unwrap();
495 }
496}