forgejo-cli/src/release.rs
2024-11-06 09:22:57 +01:00

619 lines
18 KiB
Rust

use clap::{Args, Subcommand};
use eyre::{bail, eyre, Context, OptionExt};
use forgejo_api::{
structs::{RepoCreateReleaseAttachmentQuery, RepoListReleasesQuery},
Forgejo,
};
use tokio::io::AsyncWriteExt;
use crate::{
keys::KeyInfo,
repo::{RepoArg, RepoInfo, RepoName},
SpecialRender,
};
#[derive(Args, Clone, Debug)]
pub struct ReleaseCommand {
/// The local git remote that points to the repo to operate on.
#[clap(long, short = 'R')]
remote: Option<String>,
/// The name of the repository to operate on.
#[clap(long, short, id = "[HOST/]OWNER/REPO")]
repo: Option<RepoArg>,
#[clap(subcommand)]
command: ReleaseSubcommand,
}
#[derive(Subcommand, Clone, Debug)]
pub enum ReleaseSubcommand {
/// Create a new release
Create {
name: String,
#[clap(long, short = 'T')]
/// Create a new cooresponding tag for this release. Defaults to release's name.
create_tag: Option<Option<String>>,
#[clap(long, short = 't')]
/// Pre-existing tag to use
///
/// If you need to create a new tag for this release, use `--create-tag`
tag: Option<String>,
#[clap(
long,
short,
help = "Include a file as an attachment",
long_help = "Include a file as an attachment
`--attach=<FILE>` will set the attachment's name to the file name
`--attach=<FILE>:<ASSET>` will use the provided name for the attachment"
)]
attach: Vec<String>,
#[clap(long, short)]
/// Text of the release body.
///
/// Using this flag without an argument will open your editor.
body: Option<Option<String>>,
#[clap(long, short = 'B')]
branch: Option<String>,
#[clap(long, short)]
draft: bool,
#[clap(long, short)]
prerelease: bool,
},
/// Edit a release's info
Edit {
name: String,
#[clap(long, short = 'n')]
rename: Option<String>,
#[clap(long, short = 't')]
/// Corresponding tag for this release.
tag: Option<String>,
#[clap(long, short)]
/// Text of the release body.
///
/// Using this flag without an argument will open your editor.
body: Option<Option<String>>,
#[clap(long, short)]
draft: Option<bool>,
#[clap(long, short)]
prerelease: Option<bool>,
},
/// Delete a release
Delete {
name: String,
#[clap(long, short = 't')]
by_tag: bool,
},
/// List all the releases on a repo
List {
#[clap(long, short = 'p')]
include_prerelease: bool,
#[clap(long, short = 'd')]
include_draft: bool,
},
/// View a release's info
View {
name: String,
#[clap(long, short = 't')]
by_tag: bool,
},
/// Open a release in your browser
Browse { name: Option<String> },
/// Commands on a release's attached files
#[clap(subcommand)]
Asset(AssetCommand),
}
#[derive(Subcommand, Clone, Debug)]
pub enum AssetCommand {
/// Create a new attachment on a release
Create {
release: String,
path: std::path::PathBuf,
name: Option<String>,
},
/// Remove an attachment from a release
Delete { release: String, asset: String },
/// Download an attached file
///
/// Use `source.zip` or `source.tar.gz` to download the repo archive
Download {
release: String,
asset: String,
#[clap(long, short)]
output: Option<std::path::PathBuf>,
},
}
impl ReleaseCommand {
pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> {
let repo = RepoInfo::get_current(
remote_name,
self.repo.as_ref(),
self.remote.as_deref(),
&keys,
)?;
let api = keys.get_api(repo.host_url()).await?;
let repo = repo
.name()
.ok_or_eyre("couldn't get repo name, try specifying with --repo")?;
match self.command {
ReleaseSubcommand::Create {
name,
create_tag,
tag,
attach,
body,
branch,
draft,
prerelease,
} => {
create_release(
repo, &api, name, create_tag, tag, attach, body, branch, draft, prerelease,
)
.await?
}
ReleaseSubcommand::Edit {
name,
rename,
tag,
body,
draft,
prerelease,
} => edit_release(repo, &api, name, rename, tag, body, draft, prerelease).await?,
ReleaseSubcommand::Delete { name, by_tag } => {
delete_release(repo, &api, name, by_tag).await?
}
ReleaseSubcommand::List {
include_prerelease,
include_draft,
} => list_releases(repo, &api, include_prerelease, include_draft).await?,
ReleaseSubcommand::View { name, by_tag } => {
view_release(repo, &api, name, by_tag).await?
}
ReleaseSubcommand::Browse { name } => browse_release(repo, &api, name).await?,
ReleaseSubcommand::Asset(subcommand) => match subcommand {
AssetCommand::Create {
release,
path,
name,
} => create_asset(repo, &api, release, path, name).await?,
AssetCommand::Delete { release, asset } => {
delete_asset(repo, &api, release, asset).await?
}
AssetCommand::Download {
release,
asset,
output,
} => download_asset(repo, &api, release, asset, output).await?,
},
}
Ok(())
}
}
async fn create_release(
repo: &RepoName,
api: &Forgejo,
name: String,
create_tag: Option<Option<String>>,
tag: Option<String>,
attachments: Vec<String>,
body: Option<Option<String>>,
branch: Option<String>,
draft: bool,
prerelease: bool,
) -> eyre::Result<()> {
let tag_name = match (tag, create_tag) {
(None, None) => bail!("must select tag with `--tag` or `--create-tag`"),
(Some(tag), None) => tag,
(None, Some(tag)) => {
let tag = tag.unwrap_or_else(|| name.clone());
let opt = forgejo_api::structs::CreateTagOption {
message: None,
tag_name: tag.clone(),
target: branch,
};
api.repo_create_tag(repo.owner(), repo.name(), opt).await?;
tag
}
(Some(_), Some(_)) => {
bail!("`--tag` and `--create-tag` are mutually exclusive; please pick just one")
}
};
let body = match body {
Some(Some(body)) => Some(body),
Some(None) => {
let mut s = String::new();
crate::editor(&mut s, Some("md")).await?;
Some(s)
}
None => None,
};
let release_opt = forgejo_api::structs::CreateReleaseOption {
hide_archive_links: None,
body,
draft: Some(draft),
name: Some(name.clone()),
prerelease: Some(prerelease),
tag_name,
target_commitish: None,
};
let release = api
.repo_create_release(repo.owner(), repo.name(), release_opt)
.await?;
for attachment in attachments {
let (file, asset) = match attachment.split_once(':') {
Some((file, asset)) => (std::path::Path::new(file), asset),
None => {
let file = std::path::Path::new(&attachment);
let asset = file
.file_name()
.ok_or_else(|| eyre!("{attachment} does not have a file name"))?
.to_str()
.unwrap();
(file, asset)
}
};
let query = RepoCreateReleaseAttachmentQuery {
name: Some(asset.into()),
};
let id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
api.repo_create_release_attachment(
repo.owner(),
repo.name(),
id,
tokio::fs::read(file).await?,
query,
)
.await?;
}
println!("Created release {name}");
Ok(())
}
async fn edit_release(
repo: &RepoName,
api: &Forgejo,
name: String,
rename: Option<String>,
tag: Option<String>,
body: Option<Option<String>>,
draft: Option<bool>,
prerelease: Option<bool>,
) -> eyre::Result<()> {
let release = find_release(repo, api, &name).await?;
let body = match body {
Some(Some(body)) => Some(body),
Some(None) => {
let mut s = release
.body
.clone()
.ok_or_else(|| eyre::eyre!("release does not have body"))?;
crate::editor(&mut s, Some("md")).await?;
Some(s)
}
None => None,
};
let release_edit = forgejo_api::structs::EditReleaseOption {
hide_archive_links: None,
name: rename,
tag_name: tag,
body,
draft,
prerelease,
target_commitish: None,
};
let id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
api.repo_edit_release(repo.owner(), repo.name(), id, release_edit)
.await?;
Ok(())
}
async fn list_releases(
repo: &RepoName,
api: &Forgejo,
prerelease: bool,
draft: bool,
) -> eyre::Result<()> {
let query = forgejo_api::structs::RepoListReleasesQuery {
pre_release: Some(prerelease),
draft: Some(draft),
page: None,
limit: None,
};
let releases = api
.repo_list_releases(repo.owner(), repo.name(), query)
.await?;
for release in releases {
let name = release
.name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have name"))?;
let draft = release
.draft
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have draft"))?;
let prerelease = release
.prerelease
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have prerelease"))?;
print!("{}", name);
match (draft, prerelease) {
(false, false) => (),
(true, false) => print!(" (draft)"),
(false, true) => print!(" (prerelease)"),
(true, true) => print!(" (draft, prerelease)"),
}
println!();
}
Ok(())
}
async fn view_release(
repo: &RepoName,
api: &Forgejo,
name: String,
by_tag: bool,
) -> eyre::Result<()> {
let release = if by_tag {
api.repo_get_release_by_tag(repo.owner(), repo.name(), &name)
.await?
} else {
find_release(repo, api, &name).await?
};
let name = release
.name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have name"))?;
let author = release
.author
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have author"))?;
let login = author
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("autho does not have login"))?;
let created_at = release
.created_at
.ok_or_else(|| eyre::eyre!("release does not have created_at"))?;
println!("{}", name);
print!("By {} on ", login);
created_at.format_into(
&mut std::io::stdout(),
&time::format_description::well_known::Rfc2822,
)?;
println!();
let SpecialRender { bullet, .. } = crate::special_render();
let body = release
.body
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have body"))?;
if !body.is_empty() {
println!();
println!("{}", crate::markdown(body));
println!();
}
let assets = release
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have assets"))?;
if !assets.is_empty() {
println!("{} assets", assets.len() + 2);
for asset in assets {
let name = asset
.name
.as_ref()
.ok_or_else(|| eyre::eyre!("asset does not have name"))?;
println!("{bullet} {}", name);
}
println!("{bullet} source.zip");
println!("{bullet} source.tar.gz");
}
Ok(())
}
async fn browse_release(repo: &RepoName, api: &Forgejo, name: Option<String>) -> eyre::Result<()> {
match name {
Some(name) => {
let release = find_release(repo, api, &name).await?;
let html_url = release
.html_url
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have html_url"))?;
open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
}
None => {
let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
let mut html_url = repo_data
.html_url
.clone()
.ok_or_else(|| eyre::eyre!("repository does not have html_url"))?;
html_url.path_segments_mut().unwrap().push("releases");
open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
}
}
Ok(())
}
async fn create_asset(
repo: &RepoName,
api: &Forgejo,
release: String,
file: std::path::PathBuf,
asset: Option<String>,
) -> eyre::Result<()> {
let (file, asset) = match asset {
Some(ref asset) => (&*file, &**asset),
None => {
let asset = file
.file_name()
.ok_or_else(|| eyre!("{} does not have a file name", file.display()))?
.to_str()
.unwrap();
(&*file, asset)
}
};
let id = find_release(repo, api, &release)
.await?
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
let query = RepoCreateReleaseAttachmentQuery {
name: Some(asset.to_owned()),
};
api.repo_create_release_attachment(
repo.owner(),
repo.name(),
id,
tokio::fs::read(file).await?,
query,
)
.await?;
println!("Added attachment `{}` to {}", asset, release);
Ok(())
}
async fn delete_asset(
repo: &RepoName,
api: &Forgejo,
release_name: String,
asset_name: String,
) -> eyre::Result<()> {
let release = find_release(repo, api, &release_name).await?;
let assets = release
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have assets"))?;
let asset = assets
.iter()
.find(|a| a.name.as_ref() == Some(&asset_name))
.ok_or_else(|| eyre!("asset not found"))?;
let release_id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
let asset_id = asset
.id
.ok_or_else(|| eyre::eyre!("asset does not have id"))? as u64;
api.repo_delete_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
.await?;
println!("Removed attachment `{}` from {}", asset_name, release_name);
Ok(())
}
async fn download_asset(
repo: &RepoName,
api: &Forgejo,
release: String,
asset: String,
output: Option<std::path::PathBuf>,
) -> eyre::Result<()> {
let release = find_release(repo, api, &release).await?;
let file = match &*asset {
"source.zip" => {
let tag_name = release
.tag_name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.zip", tag_name))
.await?
}
"source.tar.gz" => {
let tag_name = release
.tag_name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.tar.gz", tag_name))
.await?
}
name => {
let assets = release
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have assets"))?;
let asset = assets
.iter()
.find(|a| a.name.as_deref() == Some(name))
.ok_or_else(|| eyre!("asset not found"))?;
let release_id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))?
as u64;
let asset_id = asset
.id
.ok_or_else(|| eyre::eyre!("asset does not have id"))?
as u64;
api.download_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
.await?
.to_vec()
}
};
let real_output = output
.as_deref()
.unwrap_or_else(|| std::path::Path::new(&asset));
tokio::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(real_output)
.await?
.write_all(file.as_ref())
.await?;
if output.is_some() {
println!("Downloaded {asset} into {}", real_output.display());
} else {
println!("Downloaded {asset}");
}
Ok(())
}
async fn find_release(
repo: &RepoName,
api: &Forgejo,
name: &str,
) -> eyre::Result<forgejo_api::structs::Release> {
let query = RepoListReleasesQuery {
draft: None,
pre_release: None,
page: None,
limit: None,
};
let mut releases = api
.repo_list_releases(repo.owner(), repo.name(), query)
.await?;
let idx = releases
.iter()
.position(|r| r.name.as_deref() == Some(name))
.ok_or_else(|| eyre!("release not found"))?;
Ok(releases.swap_remove(idx))
}
async fn delete_release(
repo: &RepoName,
api: &Forgejo,
name: String,
by_tag: bool,
) -> eyre::Result<()> {
if by_tag {
api.repo_delete_release_by_tag(repo.owner(), repo.name(), &name)
.await?;
} else {
let id = find_release(repo, api, &name)
.await?
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
api.repo_delete_release(repo.owner(), repo.name(), id)
.await?;
}
Ok(())
}