1use crate::pr_generation::types::{PRFileChange, PRFileChangeType, PRRequest, PRResult};
6use crate::Error;
7use reqwest::Client;
8
9#[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 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 pub async fn create_pr(&self, request: PRRequest) -> crate::Result<PRResult> {
33 let base_sha = self.get_branch_sha(&self.base_branch).await?;
35
36 self.create_branch(&request.branch, &base_sha).await?;
38
39 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, ¤t_sha).await?
45 }
46 PRFileChangeType::Delete => {
47 self.delete_file_commit(&request.branch, file_change, ¤t_sha).await?
48 }
49 };
50 }
51
52 let pr = self.create_pull_request(&request, ¤t_sha).await?;
54
55 if !request.labels.is_empty() {
57 self.add_labels(pr.number, &request.labels).await?;
58 }
59
60 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 let blob_sha = self.create_blob(&file_change.content).await?;
137
138 let tree_sha = self.create_tree(parent_sha, &file_change.path, &blob_sha, "100644").await?;
140
141 let commit_sha = self
143 .create_commit(parent_sha, &tree_sha, &format!("Update {}", file_change.path))
144 .await?;
145
146 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 let tree_sha = self.create_tree_delete(parent_sha, &file_change.path).await?;
160
161 let commit_sha = self
163 .create_commit(parent_sha, &tree_sha, &format!("Delete {}", file_change.path))
164 .await?;
165
166 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
465trait 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 {}