use crate::pr_generation::types::{PRFileChange, PRFileChangeType, PRRequest, PRResult};
use crate::Error;
use reqwest::Client;
#[derive(Debug, Clone)]
pub struct GitHubPRClient {
owner: String,
repo: String,
token: String,
base_branch: String,
client: Client,
}
impl GitHubPRClient {
pub fn new(owner: String, repo: String, token: String, base_branch: String) -> Self {
Self {
owner,
repo,
token,
base_branch,
client: Client::new(),
}
}
pub async fn create_pr(&self, request: PRRequest) -> crate::Result<PRResult> {
let base_sha = self.get_branch_sha(&self.base_branch).await?;
self.create_branch(&request.branch, &base_sha).await?;
let mut current_sha = base_sha;
for file_change in &request.files {
current_sha = match file_change.change_type {
PRFileChangeType::Create | PRFileChangeType::Update => {
self.create_file_commit(&request.branch, file_change, ¤t_sha).await?
}
PRFileChangeType::Delete => {
self.delete_file_commit(&request.branch, file_change, ¤t_sha).await?
}
};
}
let pr = self.create_pull_request(&request, ¤t_sha).await?;
if !request.labels.is_empty() {
self.add_labels(pr.number, &request.labels).await?;
}
if !request.reviewers.is_empty() {
self.request_reviewers(pr.number, &request.reviewers).await?;
}
Ok(pr)
}
async fn get_branch_sha(&self, branch: &str) -> crate::Result<String> {
let url = format!(
"https://api.github.com/repos/{}/{}/git/ref/heads/{}",
self.owner, self.repo, branch
);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.send()
.await
.map_err(|e| Error::internal(format!("Failed to get branch: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to get branch: {}", response.status())));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse response: {}", e)))?;
json["object"]["sha"]
.as_str()
.ok_or_else(|| Error::internal("Missing SHA in response"))?
.to_string()
.pipe(Ok)
}
async fn create_branch(&self, branch: &str, sha: &str) -> crate::Result<()> {
let url = format!("https://api.github.com/repos/{}/{}/git/refs", self.owner, self.repo);
let body = serde_json::json!({
"ref": format!("refs/heads/{}", branch),
"sha": sha
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to create branch: {}", e)))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::internal(format!(
"Failed to create branch: {} - {}",
status, error_text
)));
}
Ok(())
}
async fn create_file_commit(
&self,
branch: &str,
file_change: &PRFileChange,
parent_sha: &str,
) -> crate::Result<String> {
let blob_sha = self.create_blob(&file_change.content).await?;
let tree_sha = self.create_tree(parent_sha, &file_change.path, &blob_sha, "100644").await?;
let commit_sha = self
.create_commit(parent_sha, &tree_sha, &format!("Update {}", file_change.path))
.await?;
self.update_branch_ref(branch, &commit_sha).await?;
Ok(commit_sha)
}
async fn delete_file_commit(
&self,
branch: &str,
file_change: &PRFileChange,
parent_sha: &str,
) -> crate::Result<String> {
let tree_sha = self.create_tree_delete(parent_sha, &file_change.path).await?;
let commit_sha = self
.create_commit(parent_sha, &tree_sha, &format!("Delete {}", file_change.path))
.await?;
self.update_branch_ref(branch, &commit_sha).await?;
Ok(commit_sha)
}
async fn create_blob(&self, content: &str) -> crate::Result<String> {
let url = format!("https://api.github.com/repos/{}/{}/git/blobs", self.owner, self.repo);
let body = serde_json::json!({
"content": content,
"encoding": "utf-8"
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to create blob: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to create blob: {}", response.status())));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse response: {}", e)))?;
json["sha"]
.as_str()
.ok_or_else(|| Error::internal("Missing SHA in response"))?
.to_string()
.pipe(Ok)
}
async fn create_tree(
&self,
base_tree_sha: &str,
path: &str,
blob_sha: &str,
mode: &str,
) -> crate::Result<String> {
let url = format!("https://api.github.com/repos/{}/{}/git/trees", self.owner, self.repo);
let body = serde_json::json!({
"base_tree": base_tree_sha,
"tree": [{
"path": path,
"mode": mode,
"type": "blob",
"sha": blob_sha
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to create tree: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to create tree: {}", response.status())));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse response: {}", e)))?;
json["sha"]
.as_str()
.ok_or_else(|| Error::internal("Missing SHA in response"))?
.to_string()
.pipe(Ok)
}
async fn create_tree_delete(&self, base_tree_sha: &str, path: &str) -> crate::Result<String> {
let url = format!("https://api.github.com/repos/{}/{}/git/trees", self.owner, self.repo);
let body = serde_json::json!({
"base_tree": base_tree_sha,
"tree": [{
"path": path,
"mode": "100644",
"type": "blob",
"sha": null
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to create tree: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to create tree: {}", response.status())));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse response: {}", e)))?;
json["sha"]
.as_str()
.ok_or_else(|| Error::internal("Missing SHA in response"))?
.to_string()
.pipe(Ok)
}
async fn create_commit(
&self,
parent_sha: &str,
tree_sha: &str,
message: &str,
) -> crate::Result<String> {
let url = format!("https://api.github.com/repos/{}/{}/git/commits", self.owner, self.repo);
let body = serde_json::json!({
"message": message,
"tree": tree_sha,
"parents": [parent_sha]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to create commit: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to create commit: {}", response.status())));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse response: {}", e)))?;
json["sha"]
.as_str()
.ok_or_else(|| Error::internal("Missing SHA in response"))?
.to_string()
.pipe(Ok)
}
async fn update_branch_ref(&self, branch: &str, sha: &str) -> crate::Result<()> {
let url = format!(
"https://api.github.com/repos/{}/{}/git/refs/heads/{}",
self.owner, self.repo, branch
);
let body = serde_json::json!({
"sha": sha,
"force": false
});
let response = self
.client
.patch(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to update branch: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to update branch: {}", response.status())));
}
Ok(())
}
async fn create_pull_request(
&self,
request: &PRRequest,
_head_sha: &str,
) -> crate::Result<PRResult> {
let url = format!("https://api.github.com/repos/{}/{}/pulls", self.owner, self.repo);
let body = serde_json::json!({
"title": request.title,
"body": request.body,
"head": request.branch,
"base": self.base_branch
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to create PR: {}", e)))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::internal(format!(
"Failed to create PR: {} - {}",
status, error_text
)));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse response: {}", e)))?;
Ok(PRResult {
number: json["number"].as_u64().ok_or_else(|| Error::internal("Missing PR number"))?,
url: json["html_url"]
.as_str()
.ok_or_else(|| Error::internal("Missing PR URL"))?
.to_string(),
branch: request.branch.clone(),
title: request.title.clone(),
})
}
async fn add_labels(&self, pr_number: u64, labels: &[String]) -> crate::Result<()> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/{}/labels",
self.owner, self.repo, pr_number
);
let body = serde_json::json!({
"labels": labels
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to add labels: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!("Failed to add labels: {}", response.status())));
}
Ok(())
}
async fn request_reviewers(&self, pr_number: u64, reviewers: &[String]) -> crate::Result<()> {
let url = format!(
"https://api.github.com/repos/{}/{}/pulls/{}/requested_reviewers",
self.owner, self.repo, pr_number
);
let body = serde_json::json!({
"reviewers": reviewers
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3+json")
.json(&body)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to request reviewers: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"Failed to request reviewers: {}",
response.status()
)));
}
Ok(())
}
}
trait Pipe: Sized {
fn pipe<F, R>(self, f: F) -> R
where
F: FnOnce(Self) -> R,
{
f(self)
}
}
impl<T> Pipe for T {}