use std::io::Write;
use anyhow::{anyhow, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use crate::client;
use crate::commands::issue::resolve_repo;
use crate::editor;
use crate::output::{render_record, render_table, FormatChoice};
#[derive(Debug, Subcommand)]
pub enum ReleaseCmd {
List(ListArgs),
View(ViewArgs),
Create(CreateArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
pub repo: String,
#[arg(long, default_value_t = false)]
pub include_drafts: bool,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
pub repo: String,
pub tag: String,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
pub repo: String,
#[arg(long)]
pub tag: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub body: Option<String>,
#[arg(long, default_value_t = false)]
pub draft: bool,
#[arg(long, default_value_t = false)]
pub prerelease: bool,
}
pub fn run(cmd: ReleaseCmd) -> Result<()> {
match cmd {
ReleaseCmd::List(a) => list(a),
ReleaseCmd::View(a) => view(a),
ReleaseCmd::Create(a) => create(a),
}
}
fn list(args: ListArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
let data = cli.execute(
LIST_RELEASES,
json!({ "id": repo_id, "drafts": args.include_drafts }),
)?;
let releases = data["repository"]["releases"]
.as_array()
.cloned()
.unwrap_or_default();
let rows = release_rows(&releases);
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
std::io::stdout().lock().write_all(
render_table(
&["tag", "name", "draft", "prerelease", "created"],
&rows,
fmt,
)
.as_bytes(),
)?;
Ok(())
}
fn view(args: ViewArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
let data = cli.execute(LIST_RELEASES, json!({ "id": repo_id, "drafts": true }))?;
let releases = data["repository"]["releases"]
.as_array()
.cloned()
.unwrap_or_default();
let r = releases
.iter()
.find(|r| r["tagName"].as_str() == Some(&args.tag))
.ok_or_else(|| anyhow!("no release tagged `{}`", args.tag))?;
let pairs = release_record_pairs(r, &args.tag);
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
std::io::stdout()
.lock()
.write_all(render_record(&pairs, fmt).as_bytes())?;
Ok(())
}
fn create(args: CreateArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
let body = match args.body {
Some(b) => b,
None => editor::compose("")?,
};
let data = cli.execute(
CREATE_RELEASE,
json!({
"input": {
"repositoryId": repo_id,
"tagName": args.tag,
"name": args.name,
"body": body,
"draft": args.draft,
"prerelease": args.prerelease,
}
}),
)?;
let tag = data["createRelease"]["tagName"]
.as_str()
.unwrap_or(&args.tag)
.to_string();
writeln!(
std::io::stdout().lock(),
"Created release `{tag}` in {}",
args.repo,
)?;
Ok(())
}
fn release_rows(releases: &[Value]) -> Vec<Vec<String>> {
releases
.iter()
.map(|r| {
vec![
r["tagName"].as_str().unwrap_or("").to_string(),
r["name"].as_str().unwrap_or("").to_string(),
r["draft"].as_bool().unwrap_or(false).to_string(),
r["prerelease"].as_bool().unwrap_or(false).to_string(),
r["createdAt"].as_str().unwrap_or("").to_string(),
]
})
.collect()
}
fn release_record_pairs(release: &Value, tag: &str) -> Vec<(&'static str, String)> {
vec![
("Tag", tag.to_string()),
("Name", release["name"].as_str().unwrap_or("").to_string()),
(
"Draft",
release["draft"].as_bool().unwrap_or(false).to_string(),
),
(
"Prerelease",
release["prerelease"].as_bool().unwrap_or(false).to_string(),
),
(
"Created",
release["createdAt"].as_str().unwrap_or("").to_string(),
),
("Body", release["body"].as_str().unwrap_or("").to_string()),
]
}
const LIST_RELEASES: &str = "query($id: UUID!, $drafts: Boolean!) { \
repository(id: $id) { \
releases(includeDraft: $drafts) { \
id tagName name body draft prerelease createdAt \
} \
} \
}";
const CREATE_RELEASE: &str = "mutation($input: CreateReleaseInput!) { \
createRelease(input: $input) { id tagName } \
}";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_rows_format_flags_and_defaults() {
let releases = vec![
json!({
"tagName": "v1.0.0",
"name": "One",
"draft": false,
"prerelease": true,
"createdAt": "2026-06-18T01:00:00Z"
}),
json!({}),
];
assert_eq!(
release_rows(&releases),
vec![
vec!["v1.0.0", "One", "false", "true", "2026-06-18T01:00:00Z"],
vec!["", "", "false", "false", ""],
]
);
}
#[test]
fn release_record_pairs_use_requested_tag_and_release_fields() {
let release = json!({
"tagName": "ignored",
"name": "Runtime 1.0",
"draft": true,
"prerelease": false,
"createdAt": "2026-06-18T02:00:00Z",
"body": "ship it"
});
assert_eq!(
release_record_pairs(&release, "v1.0.0"),
vec![
("Tag", "v1.0.0".to_string()),
("Name", "Runtime 1.0".to_string()),
("Draft", "true".to_string()),
("Prerelease", "false".to_string()),
("Created", "2026-06-18T02:00:00Z".to_string()),
("Body", "ship it".to_string()),
]
);
}
}