use crate::commands::version::ReleaseLatestPolicy;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
#[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 {
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, 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,
}
const GITHUB_API_BASE_URL: &str = "https://api.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<String, 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(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<String, 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(payload
.html_url
.unwrap_or_else(|| github_release_html_url(&input.owner, &input.repo, &input.tag_name)))
}
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])
}
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_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
)
}