use crate::commands::version::ReleaseLatestPolicy;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
struct GithubReleaseRequest {
tag_name: String,
target_commitish: String,
name: String,
body: String,
draft: bool,
prerelease: bool,
make_latest: String,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseResponse {
id: u64,
html_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GithubReleaseTagResponse {
pub(crate) id: u64,
pub(crate) html_url: Option<String>,
pub(crate) prerelease: Option<bool>,
pub(crate) draft: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseAssetResponse {
id: u64,
name: String,
}
#[derive(Debug, Serialize)]
struct GithubReleaseUpdateRequest {
name: String,
body: String,
draft: bool,
prerelease: bool,
make_latest: String,
}
#[derive(Debug, Clone)]
pub(crate) struct GithubReleaseInput {
pub(crate) owner: String,
pub(crate) repo: String,
pub(crate) token: String,
pub(crate) tag_name: String,
pub(crate) target_commitish: String,
pub(crate) title: String,
pub(crate) notes: String,
pub(crate) draft: bool,
pub(crate) prerelease: bool,
pub(crate) latest_policy: ReleaseLatestPolicy,
}
#[derive(Debug, Clone)]
pub(crate) struct GithubReleaseResult {
pub(crate) id: u64,
pub(crate) html_url: String,
}
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_UPLOADS_BASE_URL: &str = "https://uploads.github.com";
const GITHUB_RELEASE_ACCEPT_HEADER: &str = "application/vnd.github+json";
const GITHUB_RELEASE_USER_AGENT: &str = "xbp-cli-release/1.0";
pub(crate) async fn create_github_release(
input: &GithubReleaseInput,
) -> Result<GithubReleaseResult, String> {
let endpoint: reqwest::Url = github_release_endpoint(&input.owner, &input.repo)
.map_err(|e| format!("Failed to build GitHub create release URL: {}", e))?;
let payload: GithubReleaseRequest = GithubReleaseRequest {
tag_name: input.tag_name.clone(),
target_commitish: input.target_commitish.clone(),
name: input.title.clone(),
body: input.notes.clone(),
draft: input.draft,
prerelease: input.prerelease,
make_latest: input.latest_policy.as_github_api_value().to_string(),
};
let client: reqwest::Client = reqwest::Client::new();
let request = client.post(endpoint);
let response: reqwest::Response = github_release_headers(request, &input.token)
.json(&payload)
.send()
.await
.map_err(|e| format!("Failed to call GitHub release API: {}", e))?;
if !response.status().is_success() {
let status: reqwest::StatusCode = response.status();
let body: String = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release API failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let payload: GithubReleaseResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release response: {}", e))?;
Ok(GithubReleaseResult {
id: payload.id,
html_url: payload
.html_url
.unwrap_or_else(|| github_release_html_url(&input.owner, &input.repo, &input.tag_name)),
})
}
pub(crate) async fn get_github_release_by_tag(
input: &GithubReleaseInput,
) -> Result<Option<GithubReleaseTagResponse>, String> {
let endpoint: reqwest::Url =
github_release_by_tag_endpoint(&input.owner, &input.repo, &input.tag_name)
.map_err(|e| format!("Failed to build GitHub release lookup URL: {}", e))?;
let client: reqwest::Client = reqwest::Client::new();
let request = client.get(endpoint);
let response: reqwest::Response = github_release_headers(request, &input.token)
.send()
.await
.map_err(|e| format!("Failed to query GitHub release by tag: {}", e))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
let status: reqwest::StatusCode = response.status();
let body: String = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release lookup failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let payload: GithubReleaseTagResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release lookup response: {}", e))?;
Ok(Some(payload))
}
pub(crate) async fn update_github_release(
input: &GithubReleaseInput,
release_id: u64,
) -> Result<GithubReleaseResult, String> {
let endpoint: reqwest::Url =
github_release_update_endpoint(&input.owner, &input.repo, release_id)
.map_err(|e| format!("Failed to build GitHub release update URL: {}", e))?;
let payload: GithubReleaseUpdateRequest = GithubReleaseUpdateRequest {
name: input.title.clone(),
body: input.notes.clone(),
draft: input.draft,
prerelease: input.prerelease,
make_latest: input.latest_policy.as_github_api_value().to_string(),
};
let client: reqwest::Client = reqwest::Client::new();
let request = client.patch(endpoint);
let response: reqwest::Response = github_release_headers(request, &input.token)
.json(&payload)
.send()
.await
.map_err(|e| format!("Failed to call GitHub release update API: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release update failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let payload: GithubReleaseResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release update response: {}", e))?;
Ok(GithubReleaseResult {
id: payload.id,
html_url: payload
.html_url
.unwrap_or_else(|| github_release_html_url(&input.owner, &input.repo, &input.tag_name)),
})
}
pub(crate) async fn upload_github_release_asset(
input: &GithubReleaseInput,
release_id: u64,
asset_path: &Path,
) -> Result<(), String> {
let asset_name = asset_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("Invalid release asset path: {}", asset_path.display()))?;
let asset_bytes = fs::read(asset_path).map_err(|e| {
format!(
"Failed to read release asset {}: {}",
asset_path.display(),
e
)
})?;
let content_type = github_release_asset_content_type(asset_path);
if let Some(existing_asset_id) =
find_github_release_asset_id(input, release_id, asset_name).await?
{
delete_github_release_asset(input, existing_asset_id, asset_name).await?;
}
let endpoint =
github_release_asset_upload_endpoint(&input.owner, &input.repo, release_id, asset_name)
.map_err(|e| format!("Failed to build GitHub release asset upload URL: {}", e))?;
let client = reqwest::Client::new();
let request = client
.post(endpoint)
.header(reqwest::header::CONTENT_TYPE, content_type);
let response = github_release_headers(request, &input.token)
.body(asset_bytes)
.send()
.await
.map_err(|e| {
format!(
"Failed to upload GitHub release asset {}: {}",
asset_name, e
)
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release asset upload failed for {} with status {}: {}",
asset_name,
status,
github_api_error_message(&body)
));
}
Ok(())
}
fn github_api_error_message(body: &str) -> String {
let parsed: Option<JsonValue> = serde_json::from_str::<JsonValue>(body).ok();
let message: String = parsed
.as_ref()
.and_then(|value| value.get("message"))
.and_then(JsonValue::as_str)
.unwrap_or("unknown GitHub API error")
.to_string();
let details: String = parsed
.as_ref()
.and_then(|value| value.get("errors"))
.map(|value| value.to_string())
.unwrap_or_default();
if details.is_empty() {
message
} else {
format!("{} ({})", message, details)
}
}
pub(crate) fn github_release_endpoint(owner: &str, repo: &str) -> Result<reqwest::Url, String> {
github_api_url(&["repos", owner, repo, "releases"])
}
pub(crate) fn github_release_by_tag_endpoint(
owner: &str,
repo: &str,
tag: &str,
) -> Result<reqwest::Url, String> {
github_api_url(&["repos", owner, repo, "releases", "tags", tag])
}
pub(crate) fn github_release_update_endpoint(
owner: &str,
repo: &str,
release_id: u64,
) -> Result<reqwest::Url, String> {
let release_id_value: String = release_id.to_string();
github_api_url(&["repos", owner, repo, "releases", &release_id_value])
}
pub(crate) fn github_release_assets_endpoint(
owner: &str,
repo: &str,
release_id: u64,
) -> Result<reqwest::Url, String> {
let release_id_value = release_id.to_string();
github_api_url(&[
"repos",
owner,
repo,
"releases",
&release_id_value,
"assets",
])
}
pub(crate) fn github_release_asset_delete_endpoint(
owner: &str,
repo: &str,
asset_id: u64,
) -> Result<reqwest::Url, String> {
let asset_id_value = asset_id.to_string();
github_api_url(&["repos", owner, repo, "releases", "assets", &asset_id_value])
}
pub(crate) fn github_release_asset_upload_endpoint(
owner: &str,
repo: &str,
release_id: u64,
asset_name: &str,
) -> Result<reqwest::Url, String> {
let release_id_value = release_id.to_string();
let mut url = github_upload_url(&[
"repos",
owner,
repo,
"releases",
&release_id_value,
"assets",
])?;
url.query_pairs_mut().append_pair("name", asset_name);
Ok(url)
}
fn github_api_url(segments: &[&str]) -> Result<reqwest::Url, String> {
let mut url: reqwest::Url =
reqwest::Url::parse(GITHUB_API_BASE_URL).map_err(|e| e.to_string())?;
let mut path_segments = url
.path_segments_mut()
.map_err(|_| "invalid GitHub API base URL".to_string())?;
for segment in segments {
path_segments.push(segment);
}
drop(path_segments);
Ok(url)
}
fn github_upload_url(segments: &[&str]) -> Result<reqwest::Url, String> {
let mut url: reqwest::Url =
reqwest::Url::parse(GITHUB_UPLOADS_BASE_URL).map_err(|e| e.to_string())?;
let mut path_segments = url
.path_segments_mut()
.map_err(|_| "invalid GitHub uploads base URL".to_string())?;
for segment in segments {
path_segments.push(segment);
}
drop(path_segments);
Ok(url)
}
fn github_release_headers(
request: reqwest::RequestBuilder,
token: &str,
) -> reqwest::RequestBuilder {
request
.header(reqwest::header::ACCEPT, GITHUB_RELEASE_ACCEPT_HEADER)
.header(reqwest::header::USER_AGENT, GITHUB_RELEASE_USER_AGENT)
.bearer_auth(token)
}
fn github_release_html_url(owner: &str, repo: &str, tag_name: &str) -> String {
format!(
"https://github.com/{}/{}/releases/tag/{}",
owner, repo, tag_name
)
}
async fn find_github_release_asset_id(
input: &GithubReleaseInput,
release_id: u64,
asset_name: &str,
) -> Result<Option<u64>, String> {
let endpoint = github_release_assets_endpoint(&input.owner, &input.repo, release_id)
.map_err(|e| format!("Failed to build GitHub release asset list URL: {}", e))?;
let client = reqwest::Client::new();
let request = client.get(endpoint);
let response = github_release_headers(request, &input.token)
.send()
.await
.map_err(|e| format!("Failed to list GitHub release assets: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release asset lookup failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let assets: Vec<GithubReleaseAssetResponse> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release asset list response: {}", e))?;
Ok(assets
.into_iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.id))
}
async fn delete_github_release_asset(
input: &GithubReleaseInput,
asset_id: u64,
asset_name: &str,
) -> Result<(), String> {
let endpoint = github_release_asset_delete_endpoint(&input.owner, &input.repo, asset_id)
.map_err(|e| format!("Failed to build GitHub release asset delete URL: {}", e))?;
let client = reqwest::Client::new();
let request = client.delete(endpoint);
let response = github_release_headers(request, &input.token)
.send()
.await
.map_err(|e| {
format!(
"Failed to delete existing GitHub release asset {}: {}",
asset_name, e
)
})?;
if response.status() != reqwest::StatusCode::NO_CONTENT {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release asset delete failed for {} with status {}: {}",
asset_name,
status,
github_api_error_message(&body)
));
}
Ok(())
}
fn github_release_asset_content_type(asset_path: &Path) -> &'static str {
match asset_path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
_ => "application/octet-stream",
}
}