1use std::io::Write;
4
5use anyhow::{anyhow, Result};
6use clap::{Args, Subcommand};
7use serde_json::{json, Value};
8
9use crate::client;
10use crate::commands::issue::resolve_repo;
11use crate::editor;
12use crate::output::{render_record, render_table, FormatChoice};
13
14#[derive(Debug, Subcommand)]
15pub enum ReleaseCmd {
16 List(ListArgs),
17 View(ViewArgs),
18 Create(CreateArgs),
19}
20
21#[derive(Debug, Args)]
22pub struct ListArgs {
23 pub repo: String,
24 #[arg(long, default_value_t = false)]
25 pub include_drafts: bool,
26 #[arg(long, default_value_t = false)]
27 pub json: bool,
28 #[arg(long, default_value_t = false)]
29 pub tsv: bool,
30}
31
32#[derive(Debug, Args)]
33pub struct ViewArgs {
34 pub repo: String,
35 pub tag: String,
36 #[arg(long, default_value_t = false)]
37 pub json: bool,
38 #[arg(long, default_value_t = false)]
39 pub tsv: bool,
40}
41
42#[derive(Debug, Args)]
43pub struct CreateArgs {
44 pub repo: String,
45 #[arg(long)]
46 pub tag: String,
47 #[arg(long)]
48 pub name: String,
49 #[arg(long)]
50 pub body: Option<String>,
51 #[arg(long, default_value_t = false)]
52 pub draft: bool,
53 #[arg(long, default_value_t = false)]
54 pub prerelease: bool,
55}
56
57pub fn run(cmd: ReleaseCmd) -> Result<()> {
58 match cmd {
59 ReleaseCmd::List(a) => list(a),
60 ReleaseCmd::View(a) => view(a),
61 ReleaseCmd::Create(a) => create(a),
62 }
63}
64
65fn list(args: ListArgs) -> Result<()> {
66 let (cli, _cfg) = client::authenticated()?;
67 let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
68 let data = cli.execute(
69 LIST_RELEASES,
70 json!({ "id": repo_id, "drafts": args.include_drafts }),
71 )?;
72 let releases = data["repository"]["releases"]
73 .as_array()
74 .cloned()
75 .unwrap_or_default();
76 let rows = release_rows(&releases);
77 let fmt = FormatChoice {
78 json: args.json,
79 tsv: args.tsv,
80 }
81 .resolve();
82 std::io::stdout().lock().write_all(
83 render_table(
84 &["tag", "name", "draft", "prerelease", "created"],
85 &rows,
86 fmt,
87 )
88 .as_bytes(),
89 )?;
90 Ok(())
91}
92
93fn view(args: ViewArgs) -> Result<()> {
94 let (cli, _cfg) = client::authenticated()?;
95 let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
96 let data = cli.execute(LIST_RELEASES, json!({ "id": repo_id, "drafts": true }))?;
97 let releases = data["repository"]["releases"]
98 .as_array()
99 .cloned()
100 .unwrap_or_default();
101 let r = releases
102 .iter()
103 .find(|r| r["tagName"].as_str() == Some(&args.tag))
104 .ok_or_else(|| anyhow!("no release tagged `{}`", args.tag))?;
105 let pairs = release_record_pairs(r, &args.tag);
106 let fmt = FormatChoice {
107 json: args.json,
108 tsv: args.tsv,
109 }
110 .resolve();
111 std::io::stdout()
112 .lock()
113 .write_all(render_record(&pairs, fmt).as_bytes())?;
114 Ok(())
115}
116
117fn create(args: CreateArgs) -> Result<()> {
118 let (cli, _cfg) = client::authenticated()?;
119 let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
120 let body = match args.body {
121 Some(b) => b,
122 None => editor::compose("")?,
123 };
124 let data = cli.execute(
125 CREATE_RELEASE,
126 json!({
127 "input": {
128 "repositoryId": repo_id,
129 "tagName": args.tag,
130 "name": args.name,
131 "body": body,
132 "draft": args.draft,
133 "prerelease": args.prerelease,
134 }
135 }),
136 )?;
137 let tag = data["createRelease"]["tagName"]
138 .as_str()
139 .unwrap_or(&args.tag)
140 .to_string();
141 writeln!(
142 std::io::stdout().lock(),
143 "Created release `{tag}` in {}",
144 args.repo,
145 )?;
146 Ok(())
147}
148
149fn release_rows(releases: &[Value]) -> Vec<Vec<String>> {
150 releases
151 .iter()
152 .map(|r| {
153 vec![
154 r["tagName"].as_str().unwrap_or("").to_string(),
155 r["name"].as_str().unwrap_or("").to_string(),
156 r["draft"].as_bool().unwrap_or(false).to_string(),
157 r["prerelease"].as_bool().unwrap_or(false).to_string(),
158 r["createdAt"].as_str().unwrap_or("").to_string(),
159 ]
160 })
161 .collect()
162}
163
164fn release_record_pairs(release: &Value, tag: &str) -> Vec<(&'static str, String)> {
165 vec![
166 ("Tag", tag.to_string()),
167 ("Name", release["name"].as_str().unwrap_or("").to_string()),
168 (
169 "Draft",
170 release["draft"].as_bool().unwrap_or(false).to_string(),
171 ),
172 (
173 "Prerelease",
174 release["prerelease"].as_bool().unwrap_or(false).to_string(),
175 ),
176 (
177 "Created",
178 release["createdAt"].as_str().unwrap_or("").to_string(),
179 ),
180 ("Body", release["body"].as_str().unwrap_or("").to_string()),
181 ]
182}
183
184const LIST_RELEASES: &str = "query($id: UUID!, $drafts: Boolean!) { \
185 repository(id: $id) { \
186 releases(includeDraft: $drafts) { \
187 id tagName name body draft prerelease createdAt \
188 } \
189 } \
190}";
191
192const CREATE_RELEASE: &str = "mutation($input: CreateReleaseInput!) { \
193 createRelease(input: $input) { id tagName } \
194}";
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn release_rows_format_flags_and_defaults() {
202 let releases = vec![
203 json!({
204 "tagName": "v1.0.0",
205 "name": "One",
206 "draft": false,
207 "prerelease": true,
208 "createdAt": "2026-06-18T01:00:00Z"
209 }),
210 json!({}),
211 ];
212
213 assert_eq!(
214 release_rows(&releases),
215 vec![
216 vec!["v1.0.0", "One", "false", "true", "2026-06-18T01:00:00Z"],
217 vec!["", "", "false", "false", ""],
218 ]
219 );
220 }
221
222 #[test]
223 fn release_record_pairs_use_requested_tag_and_release_fields() {
224 let release = json!({
225 "tagName": "ignored",
226 "name": "Runtime 1.0",
227 "draft": true,
228 "prerelease": false,
229 "createdAt": "2026-06-18T02:00:00Z",
230 "body": "ship it"
231 });
232
233 assert_eq!(
234 release_record_pairs(&release, "v1.0.0"),
235 vec![
236 ("Tag", "v1.0.0".to_string()),
237 ("Name", "Runtime 1.0".to_string()),
238 ("Draft", "true".to_string()),
239 ("Prerelease", "false".to_string()),
240 ("Created", "2026-06-18T02:00:00Z".to_string()),
241 ("Body", "ship it".to_string()),
242 ]
243 );
244 }
245}