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::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) -> Result<()> {
141        self.merge_pr_in(pr_number, &self.repo_dir).await
142    }
143
144    /// Merge a pull request in a specific repo directory.
145    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}