use anyhow::{bail, Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrResult {
pub url: String,
pub number: u64,
}
#[derive(Debug, Clone)]
pub struct GitHubRemote {
pub host: String,
pub owner: String,
pub repo: String,
}
impl GitHubRemote {
pub fn api_base(&self) -> String {
if self.host == "github.com" {
"https://api.github.com".to_string()
} else {
format!("https://{}/api/v3", self.host)
}
}
#[allow(dead_code)]
pub fn browse_url(&self, path: &str) -> String {
format!(
"https://{}/{}/{}/{}",
self.host, self.owner, self.repo, path
)
}
}
pub fn parse_github_remote(url: &str) -> Option<GitHubRemote> {
if url.starts_with("git@") {
let rest = url.strip_prefix("git@")?;
let (host, path) = rest.split_once(':')?;
let path = path.trim_end_matches(".git");
let mut parts = path.splitn(2, '/');
let owner = parts.next()?.to_owned();
let repo = parts.next()?.to_owned();
return Some(GitHubRemote {
host: host.to_owned(),
owner,
repo,
});
}
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let (host, path) = rest.split_once('/')?;
let path = path.trim_end_matches(".git");
let mut parts = path.splitn(2, '/');
let owner = parts.next()?.to_owned();
let repo = parts.next()?.to_owned();
Some(GitHubRemote {
host: host.to_owned(),
owner,
repo,
})
}
fn resolve_github_token() -> Option<String> {
for var in &["PARSEC_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"] {
if let Ok(token) = std::env::var(var) {
if !token.is_empty() {
return Some(token);
}
}
}
None
}
pub async fn create_pr(
remote_url: &str,
branch: &str,
base: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<Option<PrResult>> {
let token = match resolve_github_token() {
Some(t) => t,
None => return Ok(None),
};
let remote = parse_github_remote(remote_url).ok_or_else(|| {
anyhow::anyhow!("could not parse owner/repo from remote URL: {}", remote_url)
})?;
let api_url = format!(
"{}/repos/{}/{}/pulls",
remote.api_base(),
remote.owner,
remote.repo
);
let payload = serde_json::json!({
"title": title,
"head": branch,
"base": base,
"body": body,
"draft": draft,
});
let client = Client::new();
let response = client
.post(&api_url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "git-parsec")
.bearer_auth(&token)
.json(&payload)
.send()
.await
.context("Failed to send PR creation request to GitHub")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("GitHub API returned {}: {}", status, body);
}
let resp: serde_json::Value = response
.json()
.await
.context("Failed to parse GitHub API response")?;
let html_url = resp["html_url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("GitHub response missing html_url"))?
.to_owned();
let number = resp["number"].as_u64().unwrap_or(0);
Ok(Some(PrResult {
url: html_url,
number,
}))
}