Skip to main content

oven_cli/github/
prs.rs

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    /// Create a draft pull request and return its number.
11    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    /// Create a draft pull request in a specific repo directory and return its number.
16    ///
17    /// Used in multi-repo mode where the PR belongs in the target repo, not the god repo.
18    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        // gh pr create outputs the PR URL; extract the number from it
38        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    /// Post a comment on a pull request (in the default repo).
49    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    /// Post a comment on a pull request in a specific repo directory.
54    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    /// Update the title and body of a pull request (in the default repo).
70    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    /// Update the title and body of a pull request in a specific repo directory.
75    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    /// Mark a PR as ready for review (in the default repo).
95    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    /// Mark a PR as ready for review in a specific repo directory.
100    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    /// Check the merge state of a pull request (in the default repo).
111    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    /// Check the merge state of a pull request in a specific repo directory.
116    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    /// Merge a pull request (in the default repo).
140    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    /// Merge a pull request in a specific repo directory.
145    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}