knope 0.23.0

A command line tool for automating common development tasks
use datta::UriTemplate;
use glob::glob;
use knope_config::{Asset, AssetNameError, Assets};
use miette::Diagnostic;
use relative_path::RelativePathBuf;
use tracing::info;

use crate::{
    app_config, config,
    integrations::{
        ApiRequestError, CreateReleaseInput, CreateReleaseResponse, git, http::handle_response,
    },
    state::{self, RunType},
};

pub(crate) async fn create_release(
    name: &str,
    tag_name: &str,
    body: &str,
    prerelease: bool,
    github_state: RunType<state::GitHub>,
    github_config: &config::GitHub,
    assets: Option<&Assets>,
) -> Result<state::GitHub, Error> {
    let target_commitish = git::get_head_commit_sha().ok();
    let github_release = CreateReleaseInput::new(
        tag_name,
        name,
        body,
        prerelease,
        assets.is_some(),
        target_commitish.as_deref(),
    );

    let github_state = match github_state {
        RunType::DryRun(state) => {
            github_release_dry_run(name, assets, &github_release)?;
            return Ok(state);
        }
        RunType::Real(github_state) => github_state,
    };

    let (token, client) = github_state.require_authentication()?;

    let url = format!(
        "https://api.github.com/repos/{owner}/{repo}/releases",
        owner = github_config.owner,
        repo = github_config.repo,
    );
    let token_header = format!("token {}", &token);

    let response = client
        .post(&url)
        .header("Authorization", &token_header)
        .header("Accept", "application/vnd.github+json")
        .json(&github_release)
        .send()
        .await;
    let response = handle_response(
        response,
        "GitHub".to_string(),
        "creating a release".to_string(),
    )
    .await?;

    let response: CreateReleaseResponse =
        response.json().await.map_err(|source| Error::ApiResponse {
            message: source.to_string(),
            activity: "creating a release".to_string(),
        })?;

    if let Some(assets) = assets {
        let mut upload_template = UriTemplate::new(&response.upload_url);
        for asset in resolve_assets(assets)? {
            let file = std::fs::read(asset.path.to_path("")).map_err(|source| {
                Error::CouldNotReadAssetFile {
                    path: asset.path.clone(),
                    source,
                }
            })?;
            let asset_name = asset.name()?;
            let upload_url = upload_template.set("name", asset_name.as_str()).build();
            let upload_resp = client
                .post(&upload_url)
                .header("Authorization", &token_header)
                .header("Accept", "application/vnd.github+json")
                .header("Content-Type", "application/octet-stream")
                .header("Content-Length", &file.len().to_string())
                .body(file)
                .send()
                .await;

            handle_response(
                upload_resp,
                "GitHub".to_string(),
                format!(
                    "uploading asset {asset_name}. Release has been created but not published!",
                ),
            )
            .await?;
        }
        let publish_resp = client
            .patch(&response.url)
            .header("Accept", "application/vnd.github+json")
            .header("Authorization", &token_header)
            .json(&serde_json::json!({
                "draft": false
            }))
            .send()
            .await;

        handle_response(
            publish_resp,
            "GitHub".to_string(),
            "publishing release".into(),
        )
        .await?;
    }

    Ok(state::GitHub::Authenticated { token, client })
}

fn github_release_dry_run(
    name: &str,
    assets: Option<&Assets>,
    github_release: &CreateReleaseInput,
) -> Result<(), Error> {
    let release_type = if github_release.prerelease {
        "prerelease"
    } else {
        "release"
    };
    let body = github_release.body.as_ref().map_or_else(
        || String::from("autogenerated body"),
        |body| format!("body:\n{body}"),
    );
    let target = github_release
        .target_commitish
        .map_or_else(String::new, |sha| format!(" at commit {sha}"));
    info!(
        "Would create a {release_type} on GitHub with name {name} and tag {tag}{target} and {body}",
        tag = github_release.tag_name
    );

    let Some(assets) = assets else {
        return Ok(());
    };
    info!("Would upload assets to GitHub:");

    let assets = resolve_assets(assets)?;
    for asset in assets {
        let asset_name = asset.name()?;
        info!("- {asset_name} from {path}", path = asset.path);
    }
    Ok(())
}

fn resolve_assets(assets: &Assets) -> Result<Vec<Asset>, Error> {
    match assets {
        Assets::Glob(pattern) => glob(pattern)?
            .map(|path| {
                let path = RelativePathBuf::from_path(&path?)?;
                Ok(Asset { path, name: None })
            })
            .collect(),
        Assets::List(assets) => Ok(assets.clone()),
    }
}

#[derive(Debug, Diagnostic, thiserror::Error)]
pub(crate) enum Error {
    #[error(
        "Could not read asset file {path}: {source} Release has been created but not published!"
    )]
    #[diagnostic(
        code(step::could_not_read_asset_file),
        help(
            "This could be a permissions issue or the file may not exist relative to the current working directory."
        )
    )]
    CouldNotReadAssetFile {
        path: RelativePathBuf,
        source: std::io::Error,
    },
    #[error(transparent)]
    #[diagnostic(transparent)]
    AppConfig(#[from] app_config::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    ApiRequest(#[from] ApiRequestError),
    #[error("Trouble decoding the response from GitHub while {activity}: {message}")]
    #[diagnostic(
        code(github::api_response_error),
        help(
            "Failure to decode a response from GitHub is probably a bug. Please report it at https://github.com/knope-dev/knope"
        )
    )]
    ApiResponse { message: String, activity: String },
    #[error("Asset was not uploaded to GitHub, a release was created but is still a draft! {0}")]
    #[diagnostic(
        code(github::asset_name_error),
        help("Try setting the `name` property of the asset manually"),
        url("https://knope.tech/reference/config-file/packages/#assets")
    )]
    AssetName(#[from] AssetNameError),
    #[error("Invalid glob pattern: {0}")]
    Pattern(#[from] glob::PatternError),
    #[error("Could not evaluate glob pattern: {0}")]
    Glob(#[from] glob::GlobError),
    #[error("Could not resolve asset path: {0}")]
    #[diagnostic(
        code(step::could_not_resolve_asset_path),
        help(
            "This could be a permissions issue or the file may not exist relative to the current working directory."
        )
    )]
    AssetPath(#[from] relative_path::FromPathError),
}