ref:8c23d5dcea59979014f7d8643da3eae01c4eeb0d

feat(cli): add release update/publish/upload/download/assets/delete-asset/changelog

Full release asset workflow. Upload via multipart, download by filename, publish draft releases, generate changelogs between tags. View command now shows short_id and asset list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 8c23d5dcea59979014f7d8643da3eae01c4eeb0d
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-21 18:30
Parents: 17fe32e
2 files changed +536 -0
Type
src/client.rs +73 −0
@@ -175,6 +175,79 @@
}
}
/// Upload a file as multipart form data.
pub async fn upload_file(
&self,
path: &str,
file_path: &std::path::Path,
field_name: &str,
) -> Result<serde_json::Value, ApiError> {
let file_name = file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let file_bytes = tokio::fs::read(file_path).await.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to read file: {e}"),
})?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name(file_name)
.mime_str("application/octet-stream")
.map_err(|e| ApiError::Api {
status: 0,
message: format!("Invalid MIME type: {e}"),
})?;
let form = reqwest::multipart::Form::new().part(field_name.to_string(), part);
// Build request without default Content-Type header (multipart sets its own)
let url = self.url(path);
let resp = self
.http
.post(&url)
.multipart(form)
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(resp.json().await?)
} else {
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
})
}
}
/// Download a file from the server and save to disk.
pub async fn download_file(
&self,
path: &str,
output: &std::path::Path,
) -> Result<(), ApiError> {
let resp = self.http.get(self.url(path)).send().await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::Api {
status: status.as_u16(),
message: text,
});
}
let bytes = resp.bytes().await?;
tokio::fs::write(output, &bytes).await.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to write file: {e}"),
})?;
Ok(())
}
pub fn base_url(&self) -> &str {
&self.base_url
}
src/commands/release.rs +463 −0
@@ -3,6 +3,7 @@
use crate::output;
use clap::{Args, Subcommand};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Args)]
pub struct ReleaseArgs {
@@ -48,12 +49,95 @@
},
/// Delete a release
Delete {
/// Tag name
tag: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Update a release
Update {
/// Tag name
tag: String,
/// New title
#[arg(long)]
title: Option<String>,
/// New body
#[arg(long)]
body: Option<String>,
/// Set draft flag
#[arg(long)]
draft: Option<bool>,
/// Set prerelease flag
#[arg(long)]
prerelease: Option<bool>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Publish a draft release
Publish {
/// Tag name
tag: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Upload an asset to a release
Upload {
/// Tag name
tag: String,
/// Path to file to upload
file: String,
/// Override filename (default: file's basename)
#[arg(long)]
name: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Download a release asset by filename
Download {
/// Tag name
tag: String,
/// Filename of the asset to download
filename: String,
/// Output path (default: current directory)
#[arg(long)]
output: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// List release assets
Assets {
/// Tag name
tag: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a release asset
#[command(name = "delete-asset")]
DeleteAsset {
/// Tag name
tag: String,
/// Asset short ID
asset_id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Generate changelog between two tags
Changelog {
/// Base tag (older)
base: String,
/// Head tag (newer)
head: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Deserialize)]
@@ -65,6 +149,10 @@
draft: Option<bool>,
inserted_at: Option<String>,
author: Option<ReleaseAuthor>,
#[serde(default)]
short_id: Option<String>,
#[serde(default)]
assets: Option<Vec<Asset>>,
}
#[derive(Debug, Deserialize)]
@@ -72,6 +160,25 @@
username: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Asset {
#[serde(default)]
id: Option<String>,
#[serde(default)]
short_id: Option<String>,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
download_count: Option<u64>,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
inserted_at: Option<String>,
}
pub async fn run(args: ReleaseArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
ReleaseCommand::List { repo } => list(repo.as_deref()).await,
@@ -95,5 +202,45 @@
.await
}
ReleaseCommand::Delete { tag, repo } => delete(repo.as_deref(), &tag).await,
ReleaseCommand::Update {
tag,
title,
body,
draft,
prerelease,
repo,
} => {
update(
repo.as_deref(),
&tag,
title.as_deref(),
body.as_deref(),
draft,
prerelease,
)
.await
}
ReleaseCommand::Publish { tag, repo } => publish(repo.as_deref(), &tag).await,
ReleaseCommand::Upload {
tag,
file,
name,
repo,
} => upload(repo.as_deref(), &tag, &file, name.as_deref()).await,
ReleaseCommand::Download {
tag,
filename,
output,
repo,
} => download(repo.as_deref(), &tag, &filename, output.as_deref()).await,
ReleaseCommand::Assets { tag, repo } => assets(repo.as_deref(), &tag).await,
ReleaseCommand::DeleteAsset {
tag,
asset_id,
repo,
} => delete_asset(repo.as_deref(), &tag, &asset_id).await,
ReleaseCommand::Changelog { base, head, repo } => {
changelog(repo.as_deref(), &base, &head).await
}
}
}
@@ -165,6 +312,9 @@
if let Some(ref title) = rel.title {
output::detail("Title", title);
}
if let Some(ref short_id) = rel.short_id {
output::detail("ID", short_id);
}
if rel.prerelease.unwrap_or(false) {
output::detail("Prerelease", "yes");
}
@@ -185,5 +335,25 @@
}
}
// Show assets if present
if let Some(ref asset_list) = rel.assets {
if !asset_list.is_empty() {
println!();
output::header("Assets");
let rows: Vec<Vec<String>> = asset_list
.iter()
.map(|a| {
vec![
a.filename.clone().unwrap_or_default(),
format_size(a.size.unwrap_or(0)),
a.download_count.unwrap_or(0).to_string(),
a.short_id.clone().unwrap_or_default(),
]
})
.collect();
output::print_table(&["FILENAME", "SIZE", "DOWNLOADS", "ID"], &rows);
}
}
Ok(())
}
@@ -233,4 +403,297 @@
output::success(&format!("Deleted release {tag}"));
Ok(())
}
async fn update(
repo: Option<&str>,
tag: &str,
title: Option<&str>,
body: Option<&str>,
draft: Option<bool>,
prerelease: Option<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::Map::new();
if let Some(t) = title {
payload.insert("title".to_string(), serde_json::json!(t));
}
if let Some(b) = body {
payload.insert("body".to_string(), serde_json::json!(b));
}
if let Some(d) = draft {
payload.insert("draft".to_string(), serde_json::json!(d));
}
if let Some(p) = prerelease {
payload.insert("prerelease".to_string(), serde_json::json!(p));
}
if payload.is_empty() {
output::warn("No fields to update. Use --title, --body, --draft, or --prerelease.");
return Ok(());
}
let _resp: serde_json::Value = client
.put(
&format!("/{org}/{name}/releases/{tag}"),
&serde_json::Value::Object(payload),
)
.await?;
output::success(&format!("Updated release {tag}"));
Ok(())
}
async fn publish(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.post_empty(&format!("/{org}/{name}/releases/{tag}/publish"))
.await?;
output::success(&format!("Published release {tag}"));
Ok(())
}
async fn upload(
repo: Option<&str>,
tag: &str,
file: &str,
name_override: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let file_path = PathBuf::from(file);
if !file_path.exists() {
return Err(format!("File not found: {file}").into());
}
// If a name override is given, we could rename — but the server uses the uploaded filename.
// For now we upload the file as-is; the server will use the multipart filename.
let upload_path = if let Some(override_name) = name_override {
// Create a temporary copy with the desired name
let temp_dir = std::env::temp_dir().join("anvil-upload");
tokio::fs::create_dir_all(&temp_dir).await?;
let temp_path = temp_dir.join(override_name);
tokio::fs::copy(&file_path, &temp_path).await?;
temp_path
} else {
file_path.clone()
};
let resp = client
.upload_file(
&format!("/{org}/{name}/releases/{tag}/assets"),
&upload_path,
"file",
)
.await?;
// Clean up temp file if we created one
if name_override.is_some() {
let _ = tokio::fs::remove_file(&upload_path).await;
}
let asset_name = resp
.get("asset")
.or_else(|| resp.get("data"))
.and_then(|a| a.get("filename"))
.and_then(|f| f.as_str())
.unwrap_or("(unknown)");
let asset_size = resp
.get("asset")
.or_else(|| resp.get("data"))
.and_then(|a| a.get("size"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
output::success(&format!(
"Uploaded {} ({})",
asset_name,
format_size(asset_size)
));
Ok(())
}
async fn download(
repo: Option<&str>,
tag: &str,
filename: &str,
output_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
// List assets to find the one matching the filename
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/releases/{tag}/assets"))
.await?;
let assets_val = resp
.get("assets")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
let assets: Vec<Asset> = serde_json::from_value(assets_val).unwrap_or_default();
let asset = assets
.iter()
.find(|a| a.filename.as_deref() == Some(filename))
.ok_or_else(|| format!("Asset '{filename}' not found in release {tag}"))?;
let asset_id = asset
.short_id
.as_deref()
.or(asset.id.as_deref())
.ok_or("Asset has no ID")?;
let dest = if let Some(p) = output_path {
PathBuf::from(p)
} else {
PathBuf::from(filename)
};
client
.download_file(
&format!("/{org}/{name}/releases/{tag}/assets/{asset_id}/download"),
&dest,
)
.await?;
output::success(&format!("Downloaded {} to {}", filename, dest.display()));
Ok(())
}
async fn assets(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/releases/{tag}/assets"))
.await?;
let assets_val = resp
.get("assets")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
let asset_list: Vec<Asset> = serde_json::from_value(assets_val).unwrap_or_default();
output::header(&format!("Assets for {tag} ({org}/{name})"));
let rows: Vec<Vec<String>> = asset_list
.iter()
.map(|a| {
vec![
a.filename.clone().unwrap_or_default(),
format_size(a.size.unwrap_or(0)),
a.download_count.unwrap_or(0).to_string(),
a.short_id.clone().unwrap_or_default(),
]
})
.collect();
output::print_table(&["FILENAME", "SIZE", "DOWNLOADS", "ID"], &rows);
Ok(())
}
async fn delete_asset(
repo: Option<&str>,
tag: &str,
asset_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!(
"/{org}/{name}/releases/{tag}/assets/{asset_id}"
))
.await?;
output::success(&format!("Deleted asset {asset_id} from release {tag}"));
Ok(())
}
async fn changelog(
repo: Option<&str>,
base: &str,
head: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/releases/changelog"),
&[("base", base), ("head", head)],
)
.await?;
// Try to extract the changelog text from the response
let changelog_text = resp
.get("changelog")
.and_then(|c| c.as_str())
.or_else(|| resp.get("body").and_then(|b| b.as_str()))
.or_else(|| resp.get("data").and_then(|d| d.as_str()));
if let Some(text) = changelog_text {
output::header(&format!("Changelog: {base}..{head}"));
println!("\n{text}");
} else {
// Maybe the whole response is the changelog, or has commits
if let Some(commits) = resp.get("commits").and_then(|c| c.as_array()) {
output::header(&format!("Changelog: {base}..{head} ({} commits)", commits.len()));
for commit in commits {
let sha = commit
.get("sha")
.or_else(|| commit.get("id"))
.and_then(|s| s.as_str())
.unwrap_or("???????");
let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
let message = commit
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("");
// Just show first line of the message
let first_line = message.lines().next().unwrap_or("");
println!(" {short_sha} {first_line}");
}
} else {
// Fall back to printing the whole response
println!("{}", serde_json::to_string_pretty(&resp)?);
}
}
Ok(())
}
/// Format a byte size into a human-readable string.
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}