use super::types::*;
use super::{Platform, PlatformHost};
use crate::auth::SecureString;
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
pub struct GitHubClient {
token: SecureString,
owner: String,
repo: String,
client: reqwest::Client,
api_base: String,
}
impl GitHubClient {
pub fn new(token: SecureString, owner: String, repo: String) -> Self {
Self::new_with_base(token, owner, repo, "https://api.github.com".to_string())
}
pub fn new_with_base(
token: SecureString,
owner: String,
repo: String,
api_base: String,
) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("securegit")
.build()
.expect("Failed to create HTTP client");
Self {
token,
owner,
repo,
client,
api_base,
}
}
fn auth_headers(&self) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(val) =
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token.as_str()))
{
headers.insert(reqwest::header::AUTHORIZATION, val);
}
headers.insert(
"X-GitHub-Api-Version",
reqwest::header::HeaderValue::from_static("2022-11-28"),
);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
headers
}
fn repo_url(&self) -> String {
format!("{}/repos/{}/{}", self.api_base, self.owner, self.repo)
}
}
#[async_trait]
impl Platform for GitHubClient {
fn host(&self) -> PlatformHost {
PlatformHost::GitHub
}
async fn create_pull_request(&self, pr: &CreatePR) -> Result<PullRequest> {
let url = format!("{}/pulls", self.repo_url());
let body = serde_json::json!({
"title": pr.title,
"body": pr.body,
"head": pr.head,
"base": pr.base,
"draft": pr.draft,
});
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create pull request")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let data: serde_json::Value = resp.json().await?;
Ok(PullRequest {
number: data["number"].as_u64().unwrap_or(0),
title: data["title"].as_str().unwrap_or("").to_string(),
state: data["state"].as_str().unwrap_or("open").to_string(),
html_url: data["html_url"].as_str().unwrap_or("").to_string(),
head_ref: data["head"]["ref"].as_str().unwrap_or("").to_string(),
base_ref: data["base"]["ref"].as_str().unwrap_or("").to_string(),
draft: data["draft"].as_bool().unwrap_or(false),
user: data["user"]["login"].as_str().unwrap_or("").to_string(),
created_at: data["created_at"].as_str().unwrap_or("").to_string(),
})
}
async fn list_pull_requests(&self, state: &str) -> Result<Vec<PullRequest>> {
let url = format!("{}/pulls?state={}&per_page=30", self.repo_url(), state);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to list pull requests")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let data: Vec<serde_json::Value> = resp.json().await?;
Ok(data
.into_iter()
.map(|d| PullRequest {
number: d["number"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["html_url"].as_str().unwrap_or("").to_string(),
head_ref: d["head"]["ref"].as_str().unwrap_or("").to_string(),
base_ref: d["base"]["ref"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
user: d["user"]["login"].as_str().unwrap_or("").to_string(),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
.collect())
}
async fn get_pull_request(&self, number: u64) -> Result<PullRequest> {
let url = format!("{}/pulls/{}", self.repo_url(), number);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get pull request")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(PullRequest {
number: d["number"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["html_url"].as_str().unwrap_or("").to_string(),
head_ref: d["head"]["ref"].as_str().unwrap_or("").to_string(),
base_ref: d["base"]["ref"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
user: d["user"]["login"].as_str().unwrap_or("").to_string(),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
}
async fn create_issue(&self, issue: &CreateIssue) -> Result<Issue> {
let url = format!("{}/issues", self.repo_url());
let body = serde_json::json!({
"title": issue.title,
"body": issue.body,
"labels": issue.labels,
});
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create issue")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(Issue {
number: d["number"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn search_issues(&self, query: &str) -> Result<Vec<Issue>> {
let search_query = format!("{} repo:{}/{} is:issue", query, self.owner, self.repo);
let url = format!(
"{}/search/issues?q={}",
self.api_base,
urlencoding(&search_query)
);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to search issues")?;
if !resp.status().is_success() {
return Ok(vec![]);
}
let data: serde_json::Value = resp.json().await?;
let items = data["items"].as_array().cloned().unwrap_or_default();
Ok(items
.into_iter()
.map(|d| Issue {
number: d["number"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["html_url"].as_str().unwrap_or("").to_string(),
})
.collect())
}
async fn add_labels(&self, number: u64, labels: &[String]) -> Result<()> {
let url = format!("{}/issues/{}/labels", self.repo_url(), number);
let body = serde_json::json!({ "labels": labels });
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to add labels")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error adding labels ({}): {}", status, text);
}
Ok(())
}
async fn create_release(&self, release: &CreateRelease) -> Result<Release> {
let url = format!("{}/releases", self.repo_url());
let body = serde_json::json!({
"tag_name": release.tag_name,
"name": release.name,
"body": release.body,
"draft": release.draft,
"prerelease": release.prerelease,
});
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create release")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(Release {
id: d["id"].as_u64().unwrap_or(0),
tag_name: d["tag_name"].as_str().unwrap_or("").to_string(),
name: d["name"].as_str().unwrap_or("").to_string(),
html_url: d["html_url"].as_str().unwrap_or("").to_string(),
upload_url: d["upload_url"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
prerelease: d["prerelease"].as_bool().unwrap_or(false),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
}
async fn list_releases(&self, count: usize) -> Result<Vec<Release>> {
let url = format!("{}/releases?per_page={}", self.repo_url(), count);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to list releases")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let data: Vec<serde_json::Value> = resp.json().await?;
Ok(data
.into_iter()
.map(|d| Release {
id: d["id"].as_u64().unwrap_or(0),
tag_name: d["tag_name"].as_str().unwrap_or("").to_string(),
name: d["name"].as_str().unwrap_or("").to_string(),
html_url: d["html_url"].as_str().unwrap_or("").to_string(),
upload_url: d["upload_url"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
prerelease: d["prerelease"].as_bool().unwrap_or(false),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
.collect())
}
async fn upload_release_asset(
&self,
upload_url: &str,
name: &str,
content_type: &str,
data: Vec<u8>,
) -> Result<()> {
let base_url = upload_url.split('{').next().unwrap_or(upload_url);
let url = format!("{}?name={}", base_url, urlencoding(name));
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.header("Content-Type", content_type)
.body(data)
.send()
.await
.context("Failed to upload release asset")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub upload error ({}): {}", status, text);
}
Ok(())
}
async fn get_check_runs(&self, ref_name: &str) -> Result<CombinedStatus> {
let checks_url = format!("{}/commits/{}/check-runs", self.repo_url(), ref_name);
let checks_resp = self
.client
.get(&checks_url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get check runs")?;
let mut runs = Vec::new();
if checks_resp.status().is_success() {
let data: serde_json::Value = checks_resp.json().await?;
if let Some(items) = data["check_runs"].as_array() {
for item in items {
runs.push(CheckRun {
name: item["name"].as_str().unwrap_or("").to_string(),
status: item["status"].as_str().unwrap_or("").to_string(),
conclusion: item["conclusion"].as_str().map(|s| s.to_string()),
html_url: item["html_url"].as_str().map(|s| s.to_string()),
started_at: item["started_at"].as_str().map(|s| s.to_string()),
completed_at: item["completed_at"].as_str().map(|s| s.to_string()),
});
}
}
}
let status_url = format!("{}/commits/{}/status", self.repo_url(), ref_name);
let status_resp = self
.client
.get(&status_url)
.headers(self.auth_headers())
.send()
.await;
let mut overall_state = "pending".to_string();
if let Ok(resp) = status_resp {
if resp.status().is_success() {
let data: serde_json::Value = resp.json().await?;
overall_state = data["state"].as_str().unwrap_or("pending").to_string();
if let Some(statuses) = data["statuses"].as_array() {
for s in statuses {
runs.push(CheckRun {
name: s["context"].as_str().unwrap_or("").to_string(),
status: "completed".to_string(),
conclusion: s["state"].as_str().map(|st| st.to_string()),
html_url: s["target_url"].as_str().map(|u| u.to_string()),
started_at: None,
completed_at: None,
});
}
}
}
}
if overall_state == "pending" && !runs.is_empty() {
let all_complete = runs.iter().all(|r| r.status == "completed");
let any_failed = runs
.iter()
.any(|r| r.conclusion.as_deref() == Some("failure"));
if all_complete && !any_failed {
overall_state = "success".to_string();
} else if any_failed {
overall_state = "failure".to_string();
}
}
Ok(CombinedStatus {
state: overall_state,
total_count: runs.len() as u64,
check_runs: runs,
})
}
async fn get_authenticated_user(&self) -> Result<String> {
let url = format!("{}/user", self.api_base);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get authenticated user")?;
if !resp.status().is_success() {
bail!("Authentication failed — invalid or expired token");
}
let data: serde_json::Value = resp.json().await?;
Ok(data["login"].as_str().unwrap_or("unknown").to_string())
}
async fn create_repo(&self, repo: &CreateRepo) -> Result<Repository> {
let mut body = serde_json::json!({
"name": repo.name,
"private": repo.private,
});
if let Some(ref desc) = repo.description {
body["description"] = serde_json::Value::String(desc.clone());
}
let url = if let Some(ref namespace) = repo.namespace {
format!("{}/orgs/{}/repos", self.api_base, urlencoding(namespace))
} else {
format!("{}/user/repos", self.api_base)
};
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create repository")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitHub API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(Repository {
id: d["id"].as_u64().unwrap_or(0),
name: d["name"].as_str().unwrap_or("").to_string(),
full_name: d["full_name"].as_str().unwrap_or("").to_string(),
web_url: d["html_url"].as_str().unwrap_or("").to_string(),
clone_url_http: d["clone_url"].as_str().unwrap_or("").to_string(),
clone_url_ssh: d["ssh_url"].as_str().map(|s| s.to_string()),
private: d["private"].as_bool().unwrap_or(false),
})
}
}
fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
' ' => result.push_str("%20"),
_ => {
for b in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", b));
}
}
}
}
result
}