mockforge_core/pr_generation/
github.rs

1//! GitHub PR client
2//!
3//! This module provides functionality for creating pull requests on GitHub.
4
5use crate::pr_generation::types::{PRFileChange, PRFileChangeType, PRRequest, PRResult};
6use crate::Error;
7use reqwest::Client;
8
9/// GitHub PR client
10#[derive(Debug, Clone)]
11pub struct GitHubPRClient {
12    owner: String,
13    repo: String,
14    token: String,
15    base_branch: String,
16    client: Client,
17}
18
19impl GitHubPRClient {
20    /// Create a new GitHub PR client
21    pub fn new(owner: String, repo: String, token: String, base_branch: String) -> Self {
22        Self {
23            owner,
24            repo,
25            token,
26            base_branch,
27            client: Client::new(),
28        }
29    }
30
31    /// Create a pull request
32    pub async fn create_pr(&self, request: PRRequest) -> crate::Result<PRResult> {
33        // Step 1: Get base branch SHA
34        let base_sha = self.get_branch_sha(&self.base_branch).await?;
35
36        // Step 2: Create new branch
37        self.create_branch(&request.branch, &base_sha).await?;
38
39        // Step 3: Create commits for file changes
40        let mut current_sha = base_sha;
41        for file_change in &request.files {
42            current_sha = match file_change.change_type {
43                PRFileChangeType::Create | PRFileChangeType::Update => {
44                    self.create_file_commit(&request.branch, file_change, &current_sha).await?
45                }
46                PRFileChangeType::Delete => {
47                    self.delete_file_commit(&request.branch, file_change, &current_sha).await?
48                }
49            };
50        }
51
52        // Step 4: Create pull request
53        let pr = self.create_pull_request(&request, &current_sha).await?;
54
55        // Step 5: Add labels if any
56        if !request.labels.is_empty() {
57            self.add_labels(pr.number, &request.labels).await?;
58        }
59
60        // Step 6: Request reviewers if any
61        if !request.reviewers.is_empty() {
62            self.request_reviewers(pr.number, &request.reviewers).await?;
63        }
64
65        Ok(pr)
66    }
67
68    async fn get_branch_sha(&self, branch: &str) -> crate::Result<String> {
69        let url = format!(
70            "https://api.github.com/repos/{}/{}/git/ref/heads/{}",
71            self.owner, self.repo, branch
72        );
73
74        let response = self
75            .client
76            .get(&url)
77            .header("Authorization", format!("Bearer {}", self.token))
78            .header("Accept", "application/vnd.github.v3+json")
79            .send()
80            .await
81            .map_err(|e| Error::generic(format!("Failed to get branch: {}", e)))?;
82
83        if !response.status().is_success() {
84            return Err(Error::generic(format!("Failed to get branch: {}", response.status())));
85        }
86
87        let json: serde_json::Value = response
88            .json()
89            .await
90            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
91
92        json["object"]["sha"]
93            .as_str()
94            .ok_or_else(|| Error::generic("Missing SHA in response"))?
95            .to_string()
96            .pipe(Ok)
97    }
98
99    async fn create_branch(&self, branch: &str, sha: &str) -> crate::Result<()> {
100        let url = format!("https://api.github.com/repos/{}/{}/git/refs", self.owner, self.repo);
101
102        let body = serde_json::json!({
103            "ref": format!("refs/heads/{}", branch),
104            "sha": sha
105        });
106
107        let response = self
108            .client
109            .post(&url)
110            .header("Authorization", format!("Bearer {}", self.token))
111            .header("Accept", "application/vnd.github.v3+json")
112            .json(&body)
113            .send()
114            .await
115            .map_err(|e| Error::generic(format!("Failed to create branch: {}", e)))?;
116
117        let status = response.status();
118        if !status.is_success() {
119            let error_text = response.text().await.unwrap_or_default();
120            return Err(Error::generic(format!(
121                "Failed to create branch: {} - {}",
122                status, error_text
123            )));
124        }
125
126        Ok(())
127    }
128
129    async fn create_file_commit(
130        &self,
131        branch: &str,
132        file_change: &PRFileChange,
133        parent_sha: &str,
134    ) -> crate::Result<String> {
135        // First, create blob with file content
136        let blob_sha = self.create_blob(&file_change.content).await?;
137
138        // Then, create tree with the new file
139        let tree_sha = self.create_tree(parent_sha, &file_change.path, &blob_sha, "100644").await?;
140
141        // Finally, create commit
142        let commit_sha = self
143            .create_commit(parent_sha, &tree_sha, &format!("Update {}", file_change.path))
144            .await?;
145
146        // Update branch reference
147        self.update_branch_ref(branch, &commit_sha).await?;
148
149        Ok(commit_sha)
150    }
151
152    async fn delete_file_commit(
153        &self,
154        branch: &str,
155        file_change: &PRFileChange,
156        parent_sha: &str,
157    ) -> crate::Result<String> {
158        // Create tree without the file
159        let tree_sha = self.create_tree_delete(parent_sha, &file_change.path).await?;
160
161        // Create commit
162        let commit_sha = self
163            .create_commit(parent_sha, &tree_sha, &format!("Delete {}", file_change.path))
164            .await?;
165
166        // Update branch reference
167        self.update_branch_ref(branch, &commit_sha).await?;
168
169        Ok(commit_sha)
170    }
171
172    async fn create_blob(&self, content: &str) -> crate::Result<String> {
173        let url = format!("https://api.github.com/repos/{}/{}/git/blobs", self.owner, self.repo);
174
175        let body = serde_json::json!({
176            "content": content,
177            "encoding": "utf-8"
178        });
179
180        let response = self
181            .client
182            .post(&url)
183            .header("Authorization", format!("Bearer {}", self.token))
184            .header("Accept", "application/vnd.github.v3+json")
185            .json(&body)
186            .send()
187            .await
188            .map_err(|e| Error::generic(format!("Failed to create blob: {}", e)))?;
189
190        if !response.status().is_success() {
191            return Err(Error::generic(format!("Failed to create blob: {}", response.status())));
192        }
193
194        let json: serde_json::Value = response
195            .json()
196            .await
197            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
198
199        json["sha"]
200            .as_str()
201            .ok_or_else(|| Error::generic("Missing SHA in response"))?
202            .to_string()
203            .pipe(Ok)
204    }
205
206    async fn create_tree(
207        &self,
208        base_tree_sha: &str,
209        path: &str,
210        blob_sha: &str,
211        mode: &str,
212    ) -> crate::Result<String> {
213        let url = format!("https://api.github.com/repos/{}/{}/git/trees", self.owner, self.repo);
214
215        let body = serde_json::json!({
216            "base_tree": base_tree_sha,
217            "tree": [{
218                "path": path,
219                "mode": mode,
220                "type": "blob",
221                "sha": blob_sha
222            }]
223        });
224
225        let response = self
226            .client
227            .post(&url)
228            .header("Authorization", format!("Bearer {}", self.token))
229            .header("Accept", "application/vnd.github.v3+json")
230            .json(&body)
231            .send()
232            .await
233            .map_err(|e| Error::generic(format!("Failed to create tree: {}", e)))?;
234
235        if !response.status().is_success() {
236            return Err(Error::generic(format!("Failed to create tree: {}", response.status())));
237        }
238
239        let json: serde_json::Value = response
240            .json()
241            .await
242            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
243
244        json["sha"]
245            .as_str()
246            .ok_or_else(|| Error::generic("Missing SHA in response"))?
247            .to_string()
248            .pipe(Ok)
249    }
250
251    async fn create_tree_delete(&self, base_tree_sha: &str, path: &str) -> crate::Result<String> {
252        let url = format!("https://api.github.com/repos/{}/{}/git/trees", self.owner, self.repo);
253
254        let body = serde_json::json!({
255            "base_tree": base_tree_sha,
256            "tree": [{
257                "path": path,
258                "mode": "100644",
259                "type": "blob",
260                "sha": null
261            }]
262        });
263
264        let response = self
265            .client
266            .post(&url)
267            .header("Authorization", format!("Bearer {}", self.token))
268            .header("Accept", "application/vnd.github.v3+json")
269            .json(&body)
270            .send()
271            .await
272            .map_err(|e| Error::generic(format!("Failed to create tree: {}", e)))?;
273
274        if !response.status().is_success() {
275            return Err(Error::generic(format!("Failed to create tree: {}", response.status())));
276        }
277
278        let json: serde_json::Value = response
279            .json()
280            .await
281            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
282
283        json["sha"]
284            .as_str()
285            .ok_or_else(|| Error::generic("Missing SHA in response"))?
286            .to_string()
287            .pipe(Ok)
288    }
289
290    async fn create_commit(
291        &self,
292        parent_sha: &str,
293        tree_sha: &str,
294        message: &str,
295    ) -> crate::Result<String> {
296        let url = format!("https://api.github.com/repos/{}/{}/git/commits", self.owner, self.repo);
297
298        let body = serde_json::json!({
299            "message": message,
300            "tree": tree_sha,
301            "parents": [parent_sha]
302        });
303
304        let response = self
305            .client
306            .post(&url)
307            .header("Authorization", format!("Bearer {}", self.token))
308            .header("Accept", "application/vnd.github.v3+json")
309            .json(&body)
310            .send()
311            .await
312            .map_err(|e| Error::generic(format!("Failed to create commit: {}", e)))?;
313
314        if !response.status().is_success() {
315            return Err(Error::generic(format!("Failed to create commit: {}", response.status())));
316        }
317
318        let json: serde_json::Value = response
319            .json()
320            .await
321            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
322
323        json["sha"]
324            .as_str()
325            .ok_or_else(|| Error::generic("Missing SHA in response"))?
326            .to_string()
327            .pipe(Ok)
328    }
329
330    async fn update_branch_ref(&self, branch: &str, sha: &str) -> crate::Result<()> {
331        let url = format!(
332            "https://api.github.com/repos/{}/{}/git/refs/heads/{}",
333            self.owner, self.repo, branch
334        );
335
336        let body = serde_json::json!({
337            "sha": sha,
338            "force": false
339        });
340
341        let response = self
342            .client
343            .patch(&url)
344            .header("Authorization", format!("Bearer {}", self.token))
345            .header("Accept", "application/vnd.github.v3+json")
346            .json(&body)
347            .send()
348            .await
349            .map_err(|e| Error::generic(format!("Failed to update branch: {}", e)))?;
350
351        if !response.status().is_success() {
352            return Err(Error::generic(format!("Failed to update branch: {}", response.status())));
353        }
354
355        Ok(())
356    }
357
358    async fn create_pull_request(
359        &self,
360        request: &PRRequest,
361        head_sha: &str,
362    ) -> crate::Result<PRResult> {
363        let url = format!("https://api.github.com/repos/{}/{}/pulls", self.owner, self.repo);
364
365        let body = serde_json::json!({
366            "title": request.title,
367            "body": request.body,
368            "head": request.branch,
369            "base": self.base_branch
370        });
371
372        let response = self
373            .client
374            .post(&url)
375            .header("Authorization", format!("Bearer {}", self.token))
376            .header("Accept", "application/vnd.github.v3+json")
377            .json(&body)
378            .send()
379            .await
380            .map_err(|e| Error::generic(format!("Failed to create PR: {}", e)))?;
381
382        let status = response.status();
383        if !status.is_success() {
384            let error_text = response.text().await.unwrap_or_default();
385            return Err(Error::generic(format!(
386                "Failed to create PR: {} - {}",
387                status, error_text
388            )));
389        }
390
391        let json: serde_json::Value = response
392            .json()
393            .await
394            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
395
396        Ok(PRResult {
397            number: json["number"].as_u64().ok_or_else(|| Error::generic("Missing PR number"))?,
398            url: json["html_url"]
399                .as_str()
400                .ok_or_else(|| Error::generic("Missing PR URL"))?
401                .to_string(),
402            branch: request.branch.clone(),
403            title: request.title.clone(),
404        })
405    }
406
407    async fn add_labels(&self, pr_number: u64, labels: &[String]) -> crate::Result<()> {
408        let url = format!(
409            "https://api.github.com/repos/{}/{}/issues/{}/labels",
410            self.owner, self.repo, pr_number
411        );
412
413        let body = serde_json::json!({
414            "labels": labels
415        });
416
417        let response = self
418            .client
419            .post(&url)
420            .header("Authorization", format!("Bearer {}", self.token))
421            .header("Accept", "application/vnd.github.v3+json")
422            .json(&body)
423            .send()
424            .await
425            .map_err(|e| Error::generic(format!("Failed to add labels: {}", e)))?;
426
427        if !response.status().is_success() {
428            return Err(Error::generic(format!("Failed to add labels: {}", response.status())));
429        }
430
431        Ok(())
432    }
433
434    async fn request_reviewers(&self, pr_number: u64, reviewers: &[String]) -> crate::Result<()> {
435        let url = format!(
436            "https://api.github.com/repos/{}/{}/pulls/{}/requested_reviewers",
437            self.owner, self.repo, pr_number
438        );
439
440        let body = serde_json::json!({
441            "reviewers": reviewers
442        });
443
444        let response = self
445            .client
446            .post(&url)
447            .header("Authorization", format!("Bearer {}", self.token))
448            .header("Accept", "application/vnd.github.v3+json")
449            .json(&body)
450            .send()
451            .await
452            .map_err(|e| Error::generic(format!("Failed to request reviewers: {}", e)))?;
453
454        if !response.status().is_success() {
455            return Err(Error::generic(format!(
456                "Failed to request reviewers: {}",
457                response.status()
458            )));
459        }
460
461        Ok(())
462    }
463}
464
465// Helper trait for pipe operator
466trait Pipe: Sized {
467    fn pipe<F, R>(self, f: F) -> R
468    where
469        F: FnOnce(Self) -> R,
470    {
471        f(self)
472    }
473}
474
475impl<T> Pipe for T {}