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(&[
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}