knope 0.22.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, github::initialize_state,
        handle_response,
    },
    state::{self, RunType},
};

pub(crate) 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, agent) = initialize_state(github_state)?;

    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 = agent
        .post(&url)
        .header("Authorization", &token_header)
        .send_json(github_release);
    let response = handle_response(
        response,
        "GitHub".to_string(),
        "creating a release".to_string(),
    )?;

    let response: CreateReleaseResponse =
        response
            .into_body()
            .read_json()
            .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 = agent
                .post(&upload_url)
                .header("Authorization", &token_header)
                .header("Content-Type", "application/octet-stream")
                .header("Content-Length", &file.len().to_string())
                .send(&file);

            let upload_resp = handle_response(
                upload_resp,
                "GitHub".to_string(),
                format!(
                    "uploading asset {asset_name}. Release has been created but not published!",
                ),
            )?;
            if upload_resp.status().as_u16() >= 400 {
                let num = upload_resp.status().as_u16();
                return Err(Error::ApiResponse {
                    message: format!("Got HTTP status {num}"),
                    activity: format!(
                        "uploading asset {asset_name}. Release has been created but not published!",
                    ),
                });
            }
        }
        let publish_resp = agent
            .patch(&response.url)
            .header("Authorization", &token_header)
            .send_json(serde_json::json!({
                "draft": false
            }))
            .map_err(|source| ApiRequestError {
                service: "GitHub".to_string(),
                err: source.to_string(),
                activity: "publishing release".into(),
            })?;

        if publish_resp.status().as_u16() >= 400 {
            let num = publish_resp.status().as_u16();
            return Err(Error::ApiResponse {
                message: format!(
                    "Got HTTP status {num} with body: {}",
                    publish_resp
                        .into_body()
                        .read_to_string()
                        .unwrap_or_default()
                ),
                activity: "publishing release".to_string(),
            });
        }
    }

    Ok(state::GitHub::Initialized { token, agent })
}

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),
}