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