use crate::error::ReleaseError;
use crate::release::VcsProvider;
pub struct GitHubProvider {
owner: String,
repo: String,
hostname: String,
token: String,
}
#[derive(serde::Deserialize)]
struct ReleaseResponse {
id: u64,
html_url: String,
upload_url: String,
}
impl GitHubProvider {
pub fn new(owner: String, repo: String, hostname: String, token: String) -> Self {
Self {
owner,
repo,
hostname,
token,
}
}
fn base_url(&self) -> String {
format!("https://{}/{}/{}", self.hostname, self.owner, self.repo)
}
fn api_url(&self) -> String {
if self.hostname == "github.com" {
"https://api.github.com".to_string()
} else {
format!("https://{}/api/v3", self.hostname)
}
}
fn agent(&self) -> ureq::Agent {
ureq::Agent::new_with_config(ureq::config::Config::builder().https_only(true).build())
}
fn get_release_by_tag(&self, tag: &str) -> Result<ReleaseResponse, ReleaseError> {
let url = format!(
"{}/repos/{}/{}/releases/tags/{tag}",
self.api_url(),
self.owner,
self.repo
);
let resp = self
.agent()
.get(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.call()
.map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
let release: ReleaseResponse = resp
.into_body()
.read_json()
.map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
Ok(release)
}
}
#[derive(Debug, serde::Deserialize)]
pub struct PrMetadata {
pub number: u64,
pub title: String,
pub body: Option<String>,
pub user: PrUser,
pub base: PrRef,
pub head: PrRef,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrUser {
pub login: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrRef {
#[serde(rename = "ref")]
pub ref_name: String,
}
impl GitHubProvider {
pub fn get_pr_for_branch(&self, branch: &str) -> Result<PrMetadata, ReleaseError> {
let url = format!(
"{}/repos/{}/{}/pulls?head={}:{}&state=open&per_page=1",
self.api_url(),
self.owner,
self.repo,
self.owner,
branch
);
let resp = self
.agent()
.get(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.call()
.map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
let prs: Vec<PrMetadata> = resp
.into_body()
.read_json()
.map_err(|e| ReleaseError::Vcs(format!("failed to parse PR list: {e}")))?;
prs.into_iter()
.next()
.ok_or_else(|| ReleaseError::Vcs(format!("no open PR found for branch '{branch}'")))
}
pub fn get_pr_diff(&self, pr_number: u64) -> Result<String, ReleaseError> {
let url = format!(
"{}/repos/{}/{}/pulls/{pr_number}",
self.api_url(),
self.owner,
self.repo
);
let resp = self
.agent()
.get(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github.v3.diff")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.call()
.map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
resp.into_body()
.read_to_string()
.map_err(|e| ReleaseError::Vcs(format!("failed to read PR diff: {e}")))
}
pub fn count_open_prs(&self) -> Result<(usize, usize), ReleaseError> {
let url = format!(
"{}/repos/{}/{}/pulls?state=open&per_page=100",
self.api_url(),
self.owner,
self.repo
);
let resp = self
.agent()
.get(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.call()
.map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
#[derive(serde::Deserialize)]
struct MinimalPr {
draft: Option<bool>,
}
let prs: Vec<MinimalPr> = resp
.into_body()
.read_json()
.map_err(|e| ReleaseError::Vcs(format!("failed to parse PR list: {e}")))?;
let draft_count = prs.iter().filter(|p| p.draft == Some(true)).count();
let ready_count = prs.len() - draft_count;
Ok((ready_count, draft_count))
}
pub fn post_pr_review(&self, pr_number: u64, body: &str) -> Result<(), ReleaseError> {
let url = format!(
"{}/repos/{}/{}/pulls/{pr_number}/reviews",
self.api_url(),
self.owner,
self.repo
);
let payload = serde_json::json!({
"body": body,
"event": "COMMENT",
});
self.agent()
.post(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.send_json(&payload)
.map_err(|e| ReleaseError::Vcs(format!("GitHub API POST {url}: {e}")))?;
Ok(())
}
}
impl VcsProvider for GitHubProvider {
fn create_release(
&self,
tag: &str,
name: &str,
body: &str,
prerelease: bool,
draft: bool,
) -> Result<String, ReleaseError> {
let url = format!(
"{}/repos/{}/{}/releases",
self.api_url(),
self.owner,
self.repo
);
let payload = serde_json::json!({
"tag_name": tag,
"name": name,
"body": body,
"prerelease": prerelease,
"draft": draft,
});
let resp = self
.agent()
.post(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.send_json(&payload)
.map_err(|e| ReleaseError::Vcs(format!("GitHub API POST {url}: {e}")))?;
let release: ReleaseResponse = resp
.into_body()
.read_json()
.map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
Ok(release.html_url)
}
fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
Ok(format!("{}/compare/{base}...{head}", self.base_url()))
}
fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
let url = format!(
"{}/repos/{}/{}/releases/tags/{tag}",
self.api_url(),
self.owner,
self.repo
);
match self
.agent()
.get(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.call()
{
Ok(_) => Ok(true),
Err(ureq::Error::StatusCode(404)) => Ok(false),
Err(e) => Err(ReleaseError::Vcs(format!("GitHub API GET {url}: {e}"))),
}
}
fn repo_url(&self) -> Option<String> {
Some(self.base_url())
}
fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
let release = self.get_release_by_tag(tag)?;
let url = format!(
"{}/repos/{}/{}/releases/{}",
self.api_url(),
self.owner,
self.repo,
release.id
);
self.agent()
.delete(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.call()
.map_err(|e| ReleaseError::Vcs(format!("GitHub API DELETE {url}: {e}")))?;
Ok(())
}
fn update_release(
&self,
tag: &str,
name: &str,
body: &str,
prerelease: bool,
draft: bool,
) -> Result<String, ReleaseError> {
let release = self.get_release_by_tag(tag)?;
let url = format!(
"{}/repos/{}/{}/releases/{}",
self.api_url(),
self.owner,
self.repo,
release.id
);
let payload = serde_json::json!({
"name": name,
"body": body,
"prerelease": prerelease,
"draft": draft,
});
let resp = self
.agent()
.patch(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.send_json(&payload)
.map_err(|e| ReleaseError::Vcs(format!("GitHub API PATCH {url}: {e}")))?;
let updated: ReleaseResponse = resp
.into_body()
.read_json()
.map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
Ok(updated.html_url)
}
fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
let release = self.get_release_by_tag(tag)?;
let upload_base = release
.upload_url
.split('{')
.next()
.unwrap_or(&release.upload_url);
for file_path in files {
let path = std::path::Path::new(file_path);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
let data = std::fs::read(path)
.map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
let content_type = mime_from_extension(file_name);
let url = format!("{upload_base}?name={file_name}");
let mut last_err = None;
for attempt in 0..3 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_secs(1 << attempt));
eprintln!(
"Retrying upload of {file_name} (attempt {}/3)...",
attempt + 1
);
}
match self
.agent()
.post(&url)
.header("Authorization", &format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "sr-github")
.header("Content-Type", content_type)
.send(&data[..])
{
Ok(_) => {
last_err = None;
break;
}
Err(e) => {
last_err = Some(format!("GitHub API upload asset {file_name}: {e}"));
}
}
}
if let Some(err_msg) = last_err {
return Err(ReleaseError::Vcs(err_msg));
}
}
Ok(())
}
fn verify_release(&self, tag: &str) -> Result<(), ReleaseError> {
self.get_release_by_tag(tag)?;
Ok(())
}
}
fn mime_from_extension(filename: &str) -> &'static str {
match filename.rsplit('.').next().unwrap_or("") {
"gz" | "tgz" => "application/gzip",
"zip" => "application/zip",
"tar" => "application/x-tar",
"xz" => "application/x-xz",
"bz2" => "application/x-bzip2",
"zst" | "zstd" => "application/zstd",
"deb" => "application/vnd.debian.binary-package",
"rpm" => "application/x-rpm",
"dmg" => "application/x-apple-diskimage",
"msi" => "application/x-msi",
"exe" => "application/vnd.microsoft.portable-executable",
"sig" | "asc" => "application/pgp-signature",
"sha512" => "text/plain",
"json" => "application/json",
"txt" | "md" => "text/plain",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn github_com_provider() -> GitHubProvider {
GitHubProvider::new(
"urmzd".into(),
"sr".into(),
"github.com".into(),
"test-token".into(),
)
}
fn ghes_provider() -> GitHubProvider {
GitHubProvider::new(
"org".into(),
"repo".into(),
"ghes.example.com".into(),
"test-token".into(),
)
}
#[test]
fn test_api_url_github_com() {
assert_eq!(github_com_provider().api_url(), "https://api.github.com");
}
#[test]
fn test_api_url_ghes() {
assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
}
#[test]
fn test_base_url() {
assert_eq!(
github_com_provider().base_url(),
"https://github.com/urmzd/sr"
);
assert_eq!(
ghes_provider().base_url(),
"https://ghes.example.com/org/repo"
);
}
#[test]
fn test_compare_url() {
let p = github_com_provider();
assert_eq!(
p.compare_url("v0.9.0", "v1.0.0").unwrap(),
"https://github.com/urmzd/sr/compare/v0.9.0...v1.0.0"
);
}
#[test]
fn test_repo_url() {
assert_eq!(
github_com_provider().repo_url().unwrap(),
"https://github.com/urmzd/sr"
);
}
}