Skip to main content

oven_cli/github/
prs.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use super::GhClient;
6use crate::process::CommandRunner;
7
8impl<R: CommandRunner> GhClient<R> {
9    /// Create a draft pull request and return its number.
10    pub async fn create_draft_pr(&self, title: &str, branch: &str, body: &str) -> Result<u32> {
11        self.create_draft_pr_in(title, branch, body, &self.repo_dir).await
12    }
13
14    /// Create a draft pull request in a specific repo directory and return its number.
15    ///
16    /// Used in multi-repo mode where the PR belongs in the target repo, not the god repo.
17    pub async fn create_draft_pr_in(
18        &self,
19        title: &str,
20        branch: &str,
21        body: &str,
22        repo_dir: &Path,
23    ) -> Result<u32> {
24        let output = self
25            .runner
26            .run_gh(
27                &Self::s(&[
28                    "pr", "create", "--title", title, "--body", body, "--head", branch, "--draft",
29                ]),
30                repo_dir,
31            )
32            .await
33            .context("creating draft PR")?;
34        Self::check_output(&output, "create draft PR")?;
35
36        // gh pr create outputs the PR URL; extract the number from it
37        let url = output.stdout.trim();
38        let pr_number = url
39            .rsplit('/')
40            .next()
41            .and_then(|s| s.parse::<u32>().ok())
42            .context("parsing PR number from gh output")?;
43
44        Ok(pr_number)
45    }
46
47    /// Post a comment on a pull request.
48    pub async fn comment_on_pr(&self, pr_number: u32, body: &str) -> Result<()> {
49        let output = self
50            .runner
51            .run_gh(
52                &Self::s(&["pr", "comment", &pr_number.to_string(), "--body", body]),
53                &self.repo_dir,
54            )
55            .await
56            .context("commenting on PR")?;
57        Self::check_output(&output, "comment on PR")?;
58        Ok(())
59    }
60
61    /// Mark a PR as ready for review (remove draft status).
62    pub async fn mark_pr_ready(&self, pr_number: u32) -> Result<()> {
63        let output = self
64            .runner
65            .run_gh(&Self::s(&["pr", "ready", &pr_number.to_string()]), &self.repo_dir)
66            .await
67            .context("marking PR ready")?;
68        Self::check_output(&output, "mark PR ready")?;
69        Ok(())
70    }
71
72    /// Merge a pull request.
73    pub async fn merge_pr(&self, pr_number: u32) -> Result<()> {
74        let output = self
75            .runner
76            .run_gh(
77                &Self::s(&["pr", "merge", &pr_number.to_string(), "--squash", "--delete-branch"]),
78                &self.repo_dir,
79            )
80            .await
81            .context("merging PR")?;
82        Self::check_output(&output, "merge PR")?;
83        Ok(())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use std::path::Path;
90
91    use crate::{
92        github::GhClient,
93        process::{CommandOutput, MockCommandRunner},
94    };
95
96    #[tokio::test]
97    async fn create_draft_pr_returns_number() {
98        let mut mock = MockCommandRunner::new();
99        mock.expect_run_gh().returning(|_, _| {
100            Box::pin(async {
101                Ok(CommandOutput {
102                    stdout: "https://github.com/user/repo/pull/99\n".to_string(),
103                    stderr: String::new(),
104                    success: true,
105                })
106            })
107        });
108
109        let client = GhClient::new(mock, Path::new("/tmp"));
110        let pr_number = client.create_draft_pr("title", "branch", "body").await.unwrap();
111        assert_eq!(pr_number, 99);
112    }
113
114    #[tokio::test]
115    async fn comment_on_pr_succeeds() {
116        let mut mock = MockCommandRunner::new();
117        mock.expect_run_gh().returning(|_, _| {
118            Box::pin(async {
119                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
120            })
121        });
122
123        let client = GhClient::new(mock, Path::new("/tmp"));
124        let result = client.comment_on_pr(42, "looks good").await;
125        assert!(result.is_ok());
126    }
127
128    #[tokio::test]
129    async fn mark_pr_ready_succeeds() {
130        let mut mock = MockCommandRunner::new();
131        mock.expect_run_gh().returning(|_, _| {
132            Box::pin(async {
133                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
134            })
135        });
136
137        let client = GhClient::new(mock, Path::new("/tmp"));
138        let result = client.mark_pr_ready(42).await;
139        assert!(result.is_ok());
140    }
141
142    #[tokio::test]
143    async fn merge_pr_succeeds() {
144        let mut mock = MockCommandRunner::new();
145        mock.expect_run_gh().returning(|_, _| {
146            Box::pin(async {
147                Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
148            })
149        });
150
151        let client = GhClient::new(mock, Path::new("/tmp"));
152        let result = client.merge_pr(42).await;
153        assert!(result.is_ok());
154    }
155
156    #[tokio::test]
157    async fn merge_pr_failure_propagates() {
158        let mut mock = MockCommandRunner::new();
159        mock.expect_run_gh().returning(|_, _| {
160            Box::pin(async {
161                Ok(CommandOutput {
162                    stdout: String::new(),
163                    stderr: "merge conflict".to_string(),
164                    success: false,
165                })
166            })
167        });
168
169        let client = GhClient::new(mock, Path::new("/tmp"));
170        let result = client.merge_pr(42).await;
171        assert!(result.is_err());
172        assert!(result.unwrap_err().to_string().contains("merge conflict"));
173    }
174}