xbp 10.20.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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
    )
}