Skip to main content

runtime_cli/commands/
release.rs

1//! `runtime release` — list / view / create.
2
3use 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}