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