Skip to main content

gor/cmd/
release.rs

1//! Implementation of the `gor release` subcommand.
2//!
3//! Provides release listing functionality.
4//! Currently supports `gor release list` for listing repository releases.
5
6#![allow(clippy::print_stdout)]
7
8use crate::cli::ReleaseCommand;
9use crate::client::Client;
10use crate::output::{format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13use std::io::Write;
14
15/// Run the `gor release` subcommand.
16///
17/// # Errors
18///
19/// Returns an error if the command execution fails.
20pub fn run(cmd: ReleaseCommand) -> anyhow::Result<()> {
21    match cmd {
22        ReleaseCommand::List {
23            repo,
24            limit,
25            exclude_drafts,
26            exclude_prereleases,
27            json,
28            hostname,
29        } => list(
30            repo.as_deref(),
31            limit,
32            exclude_drafts,
33            exclude_prereleases,
34            json,
35            hostname.as_deref(),
36        ),
37        ReleaseCommand::View {
38            release,
39            repo,
40            web,
41            json,
42            hostname,
43        } => view(&release, repo.as_deref(), web, json, hostname.as_deref()),
44        ReleaseCommand::Delete {
45            release,
46            repo,
47            yes,
48            skip_tag,
49            hostname,
50        } => delete(
51            &release,
52            repo.as_deref(),
53            yes,
54            skip_tag,
55            hostname.as_deref(),
56        ),
57        ReleaseCommand::Edit {
58            release,
59            repo,
60            title,
61            notes,
62            notes_file,
63            draft,
64            prerelease,
65            tag,
66            target,
67            hostname,
68        } => edit(
69            &release,
70            repo.as_deref(),
71            title.as_deref(),
72            notes.as_deref(),
73            notes_file.as_deref(),
74            draft,
75            prerelease,
76            tag.as_deref(),
77            target.as_deref(),
78            hostname.as_deref(),
79        ),
80        ReleaseCommand::Upload {
81            release,
82            files,
83            repo,
84            name,
85            mime_type,
86            hostname,
87        } => upload(
88            &release,
89            &files,
90            repo.as_deref(),
91            name.as_deref(),
92            mime_type.as_deref(),
93            hostname.as_deref(),
94        ),
95        ReleaseCommand::Create {
96            tag,
97            repo,
98            title,
99            notes,
100            notes_file,
101            draft,
102            prerelease,
103            target,
104            discussion_category,
105            hostname,
106        } => create_release(
107            &tag,
108            repo.as_deref(),
109            title.as_deref(),
110            notes.as_deref(),
111            notes_file.as_deref(),
112            draft,
113            prerelease,
114            target.as_deref(),
115            discussion_category.as_deref(),
116            hostname.as_deref(),
117        ),
118        ReleaseCommand::Download {
119            release,
120            repo,
121            patterns,
122            dir,
123            skip_existing,
124            hostname,
125        } => download(
126            &release,
127            repo.as_deref(),
128            &patterns,
129            &dir,
130            skip_existing,
131            hostname.as_deref(),
132        ),
133        ReleaseCommand::DeleteAsset {
134            asset_id,
135            repo,
136            yes,
137            hostname,
138        } => delete_asset(asset_id, repo.as_deref(), yes, hostname.as_deref()),
139    }
140}
141
142/// Execute `gor release list`.
143///
144/// Lists releases for a repository. Supports filtering by draft and prerelease
145/// status, limiting results, and JSON output with field selection.
146///
147/// # Errors
148///
149/// Returns an error if the repository cannot be found or the API request fails.
150fn list(
151    repo: Option<&str>,
152    limit: u32,
153    exclude_drafts: bool,
154    exclude_prereleases: bool,
155    json: Option<Vec<String>>,
156    hostname: Option<&str>,
157) -> anyhow::Result<()> {
158    let spec = match repo {
159        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
160        None => detect_remote().ok_or_else(|| {
161            anyhow::anyhow!(
162                "could not detect repository from current directory; specify OWNER/REPO with --repo"
163            )
164        })?,
165    };
166
167    let host = hostname.unwrap_or("github.com");
168    let client = Client::new(host).context("failed to create HTTP client")?;
169
170    let path = format!(
171        "/repos/{}/{}/releases?per_page={}",
172        spec.owner,
173        spec.repo,
174        limit.min(100)
175    );
176    let response = client.get(&path).context("failed to fetch releases")?;
177
178    let status = response.status();
179    if status == reqwest::StatusCode::NOT_FOUND {
180        anyhow::bail!("repository '{spec}' not found");
181    }
182    if !status.is_success() {
183        anyhow::bail!("failed to list releases for '{spec}': HTTP {status}");
184    }
185
186    let mut releases: Vec<serde_json::Value> = response
187        .json()
188        .context("failed to parse releases response")?;
189
190    // Client-side filtering
191    if exclude_drafts {
192        releases.retain(|r| !r["draft"].as_bool().unwrap_or(false));
193    }
194    if exclude_prereleases {
195        releases.retain(|r| !r["prerelease"].as_bool().unwrap_or(false));
196    }
197
198    // Apply limit
199    releases.truncate(limit as usize);
200
201    // Handle --json flag
202    if let Some(fields) = json {
203        let fields_ref: Option<&[String]> = if fields.is_empty() {
204            None
205        } else {
206            Some(&fields)
207        };
208        print_json(&releases, fields_ref);
209        return Ok(());
210    }
211
212    // Default: print formatted table
213    print_release_table(&releases);
214    Ok(())
215}
216
217/// Print a formatted release list table.
218///
219/// Columns: TAG NAME, RELEASE NAME, PUBLISHED, STATUS
220fn print_release_table(releases: &[serde_json::Value]) {
221    if releases.is_empty() {
222        println!("No releases found.");
223        return;
224    }
225
226    let tag_width = 20;
227    let name_width = 40;
228    let date_width = 16;
229    let status_width = 12;
230
231    println!(
232        "{:<tag_width$}  {:<name_width$}  {:<date_width$}  {:<status_width$}",
233        "TAG", "NAME", "PUBLISHED", "STATUS",
234    );
235
236    for release in releases {
237        let tag = release["tag_name"].as_str().unwrap_or("—");
238        let name = release["name"].as_str().unwrap_or("—");
239        let published = release["published_at"]
240            .as_str()
241            .map_or_else(|| "—".to_string(), format_date);
242        let is_draft = release["draft"].as_bool().unwrap_or(false);
243        let is_prerelease = release["prerelease"].as_bool().unwrap_or(false);
244        let status = if is_draft {
245            "Draft"
246        } else if is_prerelease {
247            "Pre-release"
248        } else {
249            "Latest"
250        };
251
252        let tag_truncated = crate::cmd::util::truncate(tag, tag_width);
253        let name_truncated = crate::cmd::util::truncate(name, name_width);
254
255        println!(
256            "{tag_truncated:<tag_width$}  {name_truncated:<name_width$}  {published:<date_width$}  {status:<status_width$}",
257        );
258    }
259}
260
261/// Execute `gor release view`.
262///
263/// Views details of a single release by tag name or database ID.
264/// Supports opening in browser, JSON output, and asset listing.
265///
266/// # Errors
267///
268/// Returns an error if the repository cannot be found, the release does not
269/// exist, or the API request fails.
270fn view(
271    release: &str,
272    repo: Option<&str>,
273    web: bool,
274    json: Option<Vec<String>>,
275    hostname: Option<&str>,
276) -> anyhow::Result<()> {
277    let spec = match repo {
278        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
279        None => detect_remote().ok_or_else(|| {
280            anyhow::anyhow!(
281                "could not detect repository from current directory; specify OWNER/REPO with --repo"
282            )
283        })?,
284    };
285
286    let host = hostname.unwrap_or("github.com");
287
288    // Handle --web flag: open in browser
289    if web {
290        let web_url = format!(
291            "https://{host}/{}/{}/releases/tag/{release}",
292            spec.owner, spec.repo
293        );
294        open_in_browser(&web_url);
295        return Ok(());
296    }
297
298    let client = Client::new(host).context("failed to create HTTP client")?;
299
300    // Try by tag first, then by ID
301    let tag_path = format!(
302        "/repos/{}/{}/releases/tags/{release}",
303        spec.owner, spec.repo
304    );
305    let response = client.get(&tag_path);
306
307    let release_data: serde_json::Value = match response {
308        Ok(resp) if resp.status().is_success() => {
309            resp.json().context("failed to parse release response")?
310        }
311        _ => {
312            // Try by numeric ID
313            let id: u64 = release
314                .parse()
315                .context("release not found by tag; provide a valid numeric release ID")?;
316            let id_path = format!("/repos/{}/{}/releases/{id}", spec.owner, spec.repo);
317            let resp = client.get(&id_path).context("failed to fetch release")?;
318            let status = resp.status();
319            if status == reqwest::StatusCode::NOT_FOUND {
320                anyhow::bail!("release '{release}' not found in '{spec}'");
321            }
322            if !status.is_success() {
323                anyhow::bail!("failed to view release '{release}': HTTP {status}");
324            }
325            resp.json().context("failed to parse release response")?
326        }
327    };
328
329    // Handle --json flag
330    if let Some(fields) = json {
331        let fields_ref: Option<&[String]> = if fields.is_empty() {
332            None
333        } else {
334            Some(&fields)
335        };
336        print_json(&release_data, fields_ref);
337        return Ok(());
338    }
339
340    // Print formatted view
341    print_release_view(&release_data);
342    Ok(())
343}
344
345/// Execute `gor release delete`.
346///
347/// Deletes a release by tag name or database ID. Prompts for confirmation
348/// unless `--yes` is passed. Optionally keeps the associated git tag.
349///
350/// # Errors
351///
352/// Returns an error if the repository cannot be found, the release does not
353/// exist, or the API request fails.
354fn delete(
355    release: &str,
356    repo: Option<&str>,
357    yes: bool,
358    skip_tag: bool,
359    hostname: Option<&str>,
360) -> anyhow::Result<()> {
361    let spec = match repo {
362        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
363        None => detect_remote().ok_or_else(|| {
364            anyhow::anyhow!(
365                "could not detect repository from current directory; specify OWNER/REPO with --repo"
366            )
367        })?,
368    };
369
370    let host = hostname.unwrap_or("github.com");
371    let client = Client::new(host).context("failed to create HTTP client")?;
372
373    // Resolve the release to get its ID and tag name
374    let (release_id, tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
375
376    // Confirmation prompt
377    if !yes {
378        print!("Delete release '{tag_name}' (ID: {release_id}) from '{spec}'? [y/N] ");
379        std::io::stdout().flush().ok();
380        let mut input = String::new();
381        std::io::stdin().read_line(&mut input).ok();
382        let trimmed = input.trim().to_lowercase();
383        if trimmed != "y" && trimmed != "yes" {
384            println!("Cancelled.");
385            return Ok(());
386        }
387    }
388
389    // Build the delete URL with optional skip_tag
390    let mut path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
391    if skip_tag {
392        path.push_str("?skip_tag=true");
393    }
394
395    let response = client
396        .request("DELETE", &path, &[], None)
397        .context("failed to delete release")?;
398    let status = response.status();
399
400    if status == reqwest::StatusCode::NOT_FOUND {
401        anyhow::bail!("release '{release}' not found in '{spec}'");
402    }
403    if status == reqwest::StatusCode::NO_CONTENT {
404        let tag_msg = if skip_tag { " (git tag preserved)" } else { "" };
405        println!("✓ Deleted release '{tag_name}'{tag_msg}");
406        return Ok(());
407    }
408    if !status.is_success() {
409        anyhow::bail!("failed to delete release '{release}': HTTP {status}");
410    }
411
412    Ok(())
413}
414
415/// Resolve a release identifier (tag or ID) to its numeric ID and tag name.
416///
417/// Tries by tag first, then falls back to numeric ID.
418fn resolve_release(
419    client: &Client,
420    owner: &str,
421    repo: &str,
422    release: &str,
423) -> anyhow::Result<(u64, String)> {
424    // Try by tag first
425    let tag_path = format!("/repos/{owner}/{repo}/releases/tags/{release}");
426    if let Ok(resp) = client.get(&tag_path) {
427        if resp.status().is_success() {
428            let data: serde_json::Value =
429                resp.json().context("failed to parse release response")?;
430            let id = data["id"]
431                .as_u64()
432                .context("release response missing 'id' field")?;
433            let tag = data["tag_name"].as_str().unwrap_or(release).to_string();
434            return Ok((id, tag));
435        }
436    }
437
438    // Try by numeric ID
439    let id: u64 = release
440        .parse()
441        .context("release not found by tag; provide a valid numeric release ID")?;
442    let id_path = format!("/repos/{owner}/{repo}/releases/{id}");
443    let resp = client.get(&id_path).context("failed to fetch release")?;
444    let status = resp.status();
445    if status == reqwest::StatusCode::NOT_FOUND {
446        anyhow::bail!("release '{release}' not found in '{owner}/{repo}'");
447    }
448    if !status.is_success() {
449        anyhow::bail!("failed to resolve release '{release}': HTTP {status}");
450    }
451    let data: serde_json::Value = resp.json().context("failed to parse release response")?;
452    let tag = data["tag_name"].as_str().unwrap_or(release).to_string();
453    Ok((id, tag))
454}
455
456/// Execute `gor release edit`.
457///
458/// Edits an existing release's metadata. Supports updating title, body,
459/// draft/prerelease status, tag name, and target commitish.
460///
461/// # Errors
462///
463/// Returns an error if the repository cannot be found, the release does not
464/// exist, or the API request fails.
465#[allow(clippy::too_many_arguments)]
466fn edit(
467    release: &str,
468    repo: Option<&str>,
469    title: Option<&str>,
470    notes: Option<&str>,
471    notes_file: Option<&str>,
472    draft: Option<bool>,
473    prerelease: Option<bool>,
474    tag: Option<&str>,
475    target: Option<&str>,
476    hostname: Option<&str>,
477) -> anyhow::Result<()> {
478    let spec = match repo {
479        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
480        None => detect_remote().ok_or_else(|| {
481            anyhow::anyhow!(
482                "could not detect repository from current directory; specify OWNER/REPO with --repo"
483            )
484        })?,
485    };
486
487    let host = hostname.unwrap_or("github.com");
488    let client = Client::new(host).context("failed to create HTTP client")?;
489
490    // Resolve the release to get its ID
491    let (release_id, _tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
492
493    // Read notes from file if specified
494    let body = if let Some(file) = notes_file {
495        let content = if file == "-" {
496            use std::io::Read;
497            let mut buf = String::new();
498            std::io::stdin()
499                .read_to_string(&mut buf)
500                .context("failed to read notes from stdin")?;
501            buf
502        } else {
503            std::fs::read_to_string(file)
504                .with_context(|| format!("failed to read notes file: {file}"))?
505        };
506        Some(content)
507    } else {
508        notes.map(String::from)
509    };
510
511    // Build the PATCH body
512    let mut patch = serde_json::Map::new();
513    if let Some(t) = title {
514        patch.insert("name".to_string(), serde_json::Value::String(t.to_string()));
515    }
516    if let Some(ref b) = body {
517        patch.insert("body".to_string(), serde_json::Value::String(b.clone()));
518    }
519    if let Some(d) = draft {
520        patch.insert("draft".to_string(), serde_json::Value::Bool(d));
521    }
522    if let Some(p) = prerelease {
523        patch.insert("prerelease".to_string(), serde_json::Value::Bool(p));
524    }
525    if let Some(t) = tag {
526        patch.insert(
527            "tag_name".to_string(),
528            serde_json::Value::String(t.to_string()),
529        );
530    }
531    if let Some(t) = target {
532        patch.insert(
533            "target_commitish".to_string(),
534            serde_json::Value::String(t.to_string()),
535        );
536    }
537
538    if patch.is_empty() {
539        anyhow::bail!(
540            "no changes specified; use --title, --notes, --draft, --prerelease, --tag, or --target"
541        );
542    }
543
544    let api_path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
545    let response = client
546        .request("PATCH", &api_path, &[], Some(serde_json::to_vec(&patch)?))
547        .context("failed to edit release")?;
548
549    let status = response.status();
550    if status == reqwest::StatusCode::NOT_FOUND {
551        anyhow::bail!("release '{release}' not found in '{spec}'");
552    }
553    if !status.is_success() {
554        anyhow::bail!("failed to edit release '{release}': HTTP {status}");
555    }
556
557    let updated: serde_json::Value = response.json().context("failed to parse response")?;
558    let html_url = updated["html_url"].as_str().unwrap_or("(unknown URL)");
559    println!("✓ Release updated: {html_url}");
560    Ok(())
561}
562
563/// Execute `gor release upload`.
564///
565/// Uploads one or more asset files to an existing release. Auto-detects
566/// MIME types from file extensions, with optional override.
567///
568/// # Errors
569///
570/// Returns an error if the repository cannot be found, the release does not
571/// exist, a file cannot be read, or the upload fails.
572fn upload(
573    release: &str,
574    files: &[String],
575    repo: Option<&str>,
576    name: Option<&str>,
577    mime_type: Option<&str>,
578    hostname: Option<&str>,
579) -> anyhow::Result<()> {
580    if files.is_empty() {
581        anyhow::bail!("no files specified for upload");
582    }
583
584    if name.is_some() && files.len() > 1 {
585        anyhow::bail!("--name can only be used with a single file upload");
586    }
587
588    let spec = match repo {
589        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
590        None => detect_remote().ok_or_else(|| {
591            anyhow::anyhow!(
592                "could not detect repository from current directory; specify OWNER/REPO with --repo"
593            )
594        })?,
595    };
596
597    let host = hostname.unwrap_or("github.com");
598    let client = Client::new(host).context("failed to create HTTP client")?;
599
600    // Resolve the release to get its upload URL
601    let (release_id, tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
602
603    // Fetch the full release to get the upload_url
604    let get_path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
605    let resp = client.get(&get_path).context("failed to fetch release")?;
606    let release_data: serde_json::Value =
607        resp.json().context("failed to parse release response")?;
608    let upload_url_template = release_data["upload_url"]
609        .as_str()
610        .context("release response missing 'upload_url'")?;
611
612    for file_path in files {
613        let file_name = name.unwrap_or_else(|| {
614            std::path::Path::new(file_path)
615                .file_name()
616                .and_then(|n| n.to_str())
617                .unwrap_or(file_path)
618        });
619
620        // Read the file
621        let file_data = std::fs::read(file_path)
622            .with_context(|| format!("failed to read file: {file_path}"))?;
623        let file_size = file_data.len();
624
625        // Determine MIME type
626        let content_type = mime_type.map_or_else(|| detect_mime_type(file_name), String::from);
627
628        // Build the upload URL (replace {?name,label} with ?name=...)
629        let upload_url =
630            upload_url_template.replace("{?name,label}", &format!("?name={file_name}"));
631
632        // Upload the asset
633        print!(
634            "Uploading {file_name} ({}) ... ",
635            format_size(file_size as u64)
636        );
637        std::io::stdout().flush().ok();
638
639        let response = client
640            .upload_asset(&upload_url, &file_data, &content_type)
641            .with_context(|| format!("failed to upload {file_name}"))?;
642
643        let status = response.status();
644        if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
645            anyhow::bail!(
646                "asset '{file_name}' already exists on release '{tag_name}'; use a different name or delete the existing asset first"
647            );
648        }
649        if !status.is_success() {
650            let err_body: serde_json::Value = response.json().unwrap_or_default();
651            let msg = err_body["message"].as_str().unwrap_or("upload failed");
652            anyhow::bail!("failed to upload '{file_name}': {msg}");
653        }
654
655        let asset: serde_json::Value = response.json().context("failed to parse asset response")?;
656        let asset_url = asset["browser_download_url"]
657            .as_str()
658            .unwrap_or("(unknown URL)");
659
660        println!("done");
661        println!("{asset_url}");
662    }
663
664    Ok(())
665}
666
667/// Detect MIME type from a file extension.
668fn detect_mime_type(filename: &str) -> String {
669    let ext = std::path::Path::new(filename)
670        .extension()
671        .and_then(|e| e.to_str())
672        .unwrap_or("")
673        .to_lowercase();
674
675    match ext.as_str() {
676        "zip" => "application/zip".to_string(),
677        "tar" => "application/x-tar".to_string(),
678        "gz" | "tgz" => "application/gzip".to_string(),
679        "bz2" => "application/x-bzip2".to_string(),
680        "xz" => "application/x-xz".to_string(),
681        "dmg" => "application/x-apple-diskimage".to_string(),
682        "deb" => "application/vnd.debian.binary-package".to_string(),
683        "rpm" => "application/x-rpm".to_string(),
684        "msi" => "application/x-msi".to_string(),
685        "exe" => "application/x-msdownload".to_string(),
686        "apk" => "application/vnd.android.package-archive".to_string(),
687        "jar" => "application/java-archive".to_string(),
688        "txt" => "text/plain".to_string(),
689        "md" => "text/markdown".to_string(),
690        "json" => "application/json".to_string(),
691        "xml" => "application/xml".to_string(),
692        "html" => "text/html".to_string(),
693        "css" => "text/css".to_string(),
694        "js" => "application/javascript".to_string(),
695        "wasm" => "application/wasm".to_string(),
696        "png" => "image/png".to_string(),
697        "jpg" | "jpeg" => "image/jpeg".to_string(),
698        "gif" => "image/gif".to_string(),
699        "svg" => "image/svg+xml".to_string(),
700        "ico" => "image/x-icon".to_string(),
701        "pdf" => "application/pdf".to_string(),
702        _ => "application/octet-stream".to_string(),
703    }
704}
705
706/// Execute `gor release create`.
707///
708/// Creates a new GitHub release for an existing tag. Supports draft,
709/// prerelease, notes from file/stdin, and discussion category.
710///
711/// # Errors
712///
713/// Returns an error if the repository cannot be found, the tag does not
714/// exist, or the API request fails.
715#[allow(clippy::too_many_arguments)]
716fn create_release(
717    tag: &str,
718    repo: Option<&str>,
719    title: Option<&str>,
720    notes: Option<&str>,
721    notes_file: Option<&str>,
722    draft: bool,
723    prerelease: bool,
724    target: Option<&str>,
725    discussion_category: Option<&str>,
726    hostname: Option<&str>,
727) -> anyhow::Result<()> {
728    let spec = match repo {
729        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
730        None => detect_remote().ok_or_else(|| {
731            anyhow::anyhow!(
732                "could not detect repository from current directory; specify OWNER/REPO with --repo"
733            )
734        })?,
735    };
736
737    let host = hostname.unwrap_or("github.com");
738    let client = Client::new(host).context("failed to create HTTP client")?;
739
740    // Read notes from file if specified
741    let body = if let Some(file) = notes_file {
742        let content = if file == "-" {
743            use std::io::Read;
744            let mut buf = String::new();
745            std::io::stdin()
746                .read_to_string(&mut buf)
747                .context("failed to read notes from stdin")?;
748            buf
749        } else {
750            std::fs::read_to_string(file)
751                .with_context(|| format!("failed to read notes file: {file}"))?
752        };
753        Some(content)
754    } else {
755        notes.map(String::from)
756    };
757
758    // Build the request body
759    let mut body_map = serde_json::Map::new();
760    body_map.insert(
761        "tag_name".to_string(),
762        serde_json::Value::String(tag.to_string()),
763    );
764    body_map.insert(
765        "name".to_string(),
766        serde_json::Value::String(title.unwrap_or(tag).to_string()),
767    );
768    if let Some(ref b) = body {
769        body_map.insert("body".to_string(), serde_json::Value::String(b.clone()));
770    }
771    if draft {
772        body_map.insert("draft".to_string(), serde_json::Value::Bool(true));
773    }
774    if prerelease {
775        body_map.insert("prerelease".to_string(), serde_json::Value::Bool(true));
776    }
777    if let Some(t) = target {
778        body_map.insert(
779            "target_commitish".to_string(),
780            serde_json::Value::String(t.to_string()),
781        );
782    }
783    if let Some(c) = discussion_category {
784        body_map.insert(
785            "discussion_category_name".to_string(),
786            serde_json::Value::String(c.to_string()),
787        );
788    }
789
790    let path = format!("/repos/{}/{}/releases", spec.owner, spec.repo);
791    let body_value = serde_json::Value::Object(body_map);
792    let response = client
793        .post(&path, &body_value)
794        .context("failed to create release")?;
795
796    let status = response.status();
797    if status == reqwest::StatusCode::NOT_FOUND {
798        anyhow::bail!("repository '{spec}' not found");
799    }
800    if !status.is_success() {
801        let err_body: serde_json::Value = response.json().unwrap_or_default();
802        let msg = err_body["message"].as_str().unwrap_or("creation failed");
803        anyhow::bail!("failed to create release: {msg}");
804    }
805
806    let release_data: serde_json::Value = response
807        .json()
808        .context("failed to parse release response")?;
809    let html_url = release_data["html_url"].as_str().unwrap_or("(unknown URL)");
810    println!("{html_url}");
811    Ok(())
812}
813
814/// Execute `gor release download`.
815///
816/// Downloads assets from a release. Supports glob pattern filtering,
817/// output directory selection, and skipping existing files.
818///
819/// # Errors
820///
821/// Returns an error if the repository cannot be found, the release does not
822/// exist, no assets match the pattern, or a download fails.
823fn download(
824    release: &str,
825    repo: Option<&str>,
826    patterns: &[String],
827    dir: &str,
828    skip_existing: bool,
829    hostname: Option<&str>,
830) -> anyhow::Result<()> {
831    let spec = match repo {
832        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
833        None => detect_remote().ok_or_else(|| {
834            anyhow::anyhow!(
835                "could not detect repository from current directory; specify OWNER/REPO with --repo"
836            )
837        })?,
838    };
839
840    let host = hostname.unwrap_or("github.com");
841    let client = Client::new(host).context("failed to create HTTP client")?;
842
843    // Resolve the release
844    let (release_id, _tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
845
846    // Fetch the release to get assets
847    let get_path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
848    let resp = client.get(&get_path).context("failed to fetch release")?;
849    let release_data: serde_json::Value =
850        resp.json().context("failed to parse release response")?;
851
852    let assets = release_data["assets"]
853        .as_array()
854        .map_or(&[] as &[serde_json::Value], |a| a);
855
856    // Filter assets by patterns
857    let filtered: Vec<&serde_json::Value> = if patterns.is_empty() {
858        assets.iter().collect()
859    } else {
860        assets
861            .iter()
862            .filter(|asset| {
863                let name = asset["name"].as_str().unwrap_or("");
864                patterns.iter().any(|p| glob_match(p, name))
865            })
866            .collect()
867    };
868
869    if filtered.is_empty() {
870        if patterns.is_empty() {
871            anyhow::bail!("release has no assets to download");
872        }
873        anyhow::bail!("no assets match the given pattern(s)");
874    }
875
876    // Ensure output directory exists
877    std::fs::create_dir_all(dir)
878        .with_context(|| format!("failed to create output directory: {dir}"))?;
879
880    for asset in &filtered {
881        let asset_name = asset["name"].as_str().unwrap_or("unknown");
882        let asset_url = asset["browser_download_url"]
883            .as_str()
884            .context("asset missing download URL")?;
885        let asset_size = asset["size"].as_u64().unwrap_or(0);
886
887        let output_path = std::path::Path::new(dir).join(asset_name);
888
889        // Skip if file exists
890        if skip_existing && output_path.exists() {
891            println!("Skipping {asset_name} (already exists)");
892            continue;
893        }
894
895        // Download the asset
896        print!(
897            "Downloading {asset_name} ({}) ... ",
898            format_size(asset_size)
899        );
900        std::io::stdout().flush().ok();
901
902        let response = client
903            .get_absolute(asset_url)
904            .with_context(|| format!("failed to download {asset_name}"))?;
905
906        let status = response.status();
907        if !status.is_success() {
908            anyhow::bail!("failed to download '{asset_name}': HTTP {status}");
909        }
910
911        let data = response
912            .bytes()
913            .with_context(|| format!("failed to read response body for {asset_name}"))?;
914
915        std::fs::write(&output_path, &data)
916            .with_context(|| format!("failed to write {}", output_path.display()))?;
917
918        println!("done");
919        println!("{}", output_path.display());
920    }
921
922    Ok(())
923}
924
925/// Execute `gor release delete-asset`.
926///
927/// Deletes a specific asset from a release.
928///
929/// # Errors
930///
931/// Returns an error if the asset does not exist or the API request fails.
932fn delete_asset(
933    asset_id: u64,
934    repo: Option<&str>,
935    yes: bool,
936    hostname: Option<&str>,
937) -> anyhow::Result<()> {
938    if !yes {
939        use std::io::Write;
940        print!("Are you sure you want to delete asset {asset_id}? [y/N] ");
941        std::io::stdout().flush().ok();
942
943        let mut input = String::new();
944        std::io::stdin()
945            .read_line(&mut input)
946            .context("failed to read input")?;
947        let input = input.trim().to_lowercase();
948        if input != "y" && input != "yes" {
949            println!("Cancelled.");
950            return Ok(());
951        }
952    }
953
954    let host = hostname.unwrap_or("github.com");
955    let client = Client::new(host).context("failed to create HTTP client")?;
956
957    let spec = if let Some(r) = repo {
958        parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
959    } else {
960        detect_remote().context("could not detect repository from git remote")?
961    };
962
963    let path = format!(
964        "/repos/{}/{}/releases/assets/{asset_id}",
965        spec.owner, spec.repo
966    );
967
968    let response = client
969        .request("DELETE", &path, &[], None)
970        .context("failed to delete asset")?;
971
972    let status = response.status();
973    if !status.is_success() {
974        let err_body: serde_json::Value = response.json().unwrap_or_default();
975        let msg = err_body["message"].as_str().unwrap_or("delete failed");
976        anyhow::bail!("failed to delete asset {asset_id}: {msg}");
977    }
978
979    println!("Asset {asset_id} deleted.");
980    Ok(())
981}
982
983/// Simple glob pattern matching for asset filtering.
984///
985/// Supports `*` (any characters) and `?` (single character) wildcards.
986fn glob_match(pattern: &str, name: &str) -> bool {
987    let pattern = pattern.to_lowercase();
988    let name = name.to_lowercase();
989
990    // If no wildcards, do exact match
991    if !pattern.contains('*') && !pattern.contains('?') {
992        return pattern == name;
993    }
994
995    // Simple recursive glob matching
996    glob_match_impl(&pattern, &name)
997}
998
999fn glob_match_impl(pattern: &str, name: &str) -> bool {
1000    let mut pi = pattern.chars();
1001    let mut ni = name.chars();
1002
1003    loop {
1004        match (pi.next(), ni.next()) {
1005            (None | Some('*'), None) => return true,
1006            (Some('*'), Some(_)) => {
1007                let rest_pattern: String = pi.collect();
1008                if rest_pattern.is_empty() {
1009                    return true;
1010                }
1011                let remaining: String = ni.collect();
1012                for i in 0..=remaining.len() {
1013                    if glob_match_impl(&rest_pattern, &remaining[i..]) {
1014                        return true;
1015                    }
1016                }
1017                return false;
1018            }
1019            (Some('?'), Some(_)) => {}
1020            (Some(pc), Some(nc)) if pc == nc => {}
1021            _ => return false,
1022        }
1023    }
1024}
1025
1026/// Print a formatted single-release view.
1027///
1028/// Displays tag, name, status, published date, author, URL, body, and assets.
1029fn print_release_view(release: &serde_json::Value) {
1030    let tag = release["tag_name"].as_str().unwrap_or("—");
1031    let name = release["name"].as_str().unwrap_or("—");
1032    let body = release["body"].as_str().unwrap_or("");
1033    let html_url = release["html_url"].as_str().unwrap_or("—");
1034    let published = release["published_at"]
1035        .as_str()
1036        .map_or_else(|| "—".to_string(), format_date);
1037    let author = release["author"]["login"].as_str().unwrap_or("—");
1038    let is_draft = release["draft"].as_bool().unwrap_or(false);
1039    let is_prerelease = release["prerelease"].as_bool().unwrap_or(false);
1040    let status = if is_draft {
1041        "Draft"
1042    } else if is_prerelease {
1043        "Pre-release"
1044    } else {
1045        "Latest"
1046    };
1047
1048    println!("Tag:       {tag}");
1049    println!("Name:      {name}");
1050    println!("Status:    {status}");
1051    println!("Published: {published}");
1052    println!("Author:    {author}");
1053    println!("URL:       {html_url}");
1054
1055    if !body.is_empty() {
1056        println!();
1057        println!("{body}");
1058    }
1059
1060    // Assets
1061    let assets = release["assets"].as_array();
1062    if let Some(assets) = assets {
1063        if !assets.is_empty() {
1064            println!();
1065            println!("Assets:");
1066            let name_width = 40;
1067            let size_width = 12;
1068            let dl_width = 12;
1069            println!(
1070                "  {:<name_width$}  {:>size_width$}  {:>dl_width$}",
1071                "NAME", "SIZE", "DOWNLOADS"
1072            );
1073            for asset in assets {
1074                let asset_name = asset["name"].as_str().unwrap_or("—");
1075                let size = asset["size"]
1076                    .as_u64()
1077                    .map_or_else(|| "—".to_string(), format_size);
1078                let downloads = asset["download_count"]
1079                    .as_u64()
1080                    .map_or_else(|| "—".to_string(), |d| d.to_string());
1081                let name_truncated = crate::cmd::util::truncate(asset_name, name_width);
1082                println!(
1083                    "  {name_truncated:<name_width$}  {size:>size_width$}  {downloads:>dl_width$}"
1084                );
1085            }
1086        }
1087    }
1088}
1089
1090/// Format a byte size as a human-readable string.
1091#[allow(clippy::cast_precision_loss)]
1092fn format_size(bytes: u64) -> String {
1093    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
1094    let mut size = bytes as f64;
1095    let mut unit_idx = 0;
1096    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
1097        size /= 1024.0;
1098        unit_idx += 1;
1099    }
1100    if unit_idx == 0 {
1101        format!("{bytes} B")
1102    } else {
1103        format!("{size:.1} {}", UNITS[unit_idx])
1104    }
1105}
1106
1107/// Open a URL in the default browser using the system's default handler.
1108fn open_in_browser(url: &str) {
1109    #[cfg(target_os = "linux")]
1110    {
1111        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1112    }
1113    #[cfg(target_os = "macos")]
1114    {
1115        let _ = std::process::Command::new("open").arg(url).spawn();
1116    }
1117    #[cfg(target_os = "windows")]
1118    {
1119        let _ = std::process::Command::new("cmd")
1120            .args(["/c", "start", url])
1121            .spawn();
1122    }
1123}
1124
1125#[cfg(test)]
1126#[allow(clippy::expect_used)]
1127mod tests {
1128    use super::*;
1129    use serde_json::json;
1130
1131    #[test]
1132    fn print_release_table_basic() {
1133        let releases = vec![json!({
1134            "tag_name": "v1.0.0",
1135            "name": "First Release",
1136            "published_at": "2024-01-15T10:30:00Z",
1137            "draft": false,
1138            "prerelease": false
1139        })];
1140        print_release_table(&releases);
1141    }
1142
1143    #[test]
1144    fn print_release_table_empty() {
1145        let releases: Vec<serde_json::Value> = vec![];
1146        print_release_table(&releases);
1147    }
1148
1149    #[test]
1150    fn print_release_table_multiple() {
1151        let releases = vec![
1152            json!({
1153                "tag_name": "v1.0.0",
1154                "name": "First Release",
1155                "published_at": "2024-01-15T10:30:00Z",
1156                "draft": false,
1157                "prerelease": false
1158            }),
1159            json!({
1160                "tag_name": "v2.0.0-beta",
1161                "name": "Beta Release",
1162                "published_at": "2024-03-01T00:00:00Z",
1163                "draft": false,
1164                "prerelease": true
1165            }),
1166            json!({
1167                "tag_name": "v2.0.0-wip",
1168                "name": "Work in Progress",
1169                "published_at": null,
1170                "draft": true,
1171                "prerelease": false
1172            }),
1173        ];
1174        print_release_table(&releases);
1175    }
1176
1177    #[test]
1178    fn print_release_table_null_fields() {
1179        let releases = vec![json!({
1180            "tag_name": null,
1181            "name": null,
1182            "published_at": null,
1183            "draft": null,
1184            "prerelease": null
1185        })];
1186        print_release_table(&releases);
1187    }
1188
1189    #[test]
1190    fn print_release_view_basic() {
1191        let release = json!({
1192            "tag_name": "v1.0.0",
1193            "name": "First Release",
1194            "body": "Release notes here",
1195            "html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
1196            "published_at": "2024-01-15T10:30:00Z",
1197            "author": { "login": "octocat" },
1198            "draft": false,
1199            "prerelease": false,
1200            "assets": [
1201                {
1202                    "name": "app-linux-amd64.tar.gz",
1203                    "size": 5_242_880,
1204                    "download_count": 42
1205                },
1206                {
1207                    "name": "app-darwin-amd64.tar.gz",
1208                    "size": 4_194_304,
1209                    "download_count": 18
1210                }
1211            ]
1212        });
1213        print_release_view(&release);
1214    }
1215
1216    #[test]
1217    fn print_release_view_draft() {
1218        let release = json!({
1219            "tag_name": "v2.0.0-beta",
1220            "name": null,
1221            "body": "",
1222            "html_url": "https://github.com/owner/repo/releases/tag/v2.0.0-beta",
1223            "published_at": null,
1224            "author": null,
1225            "draft": true,
1226            "prerelease": false,
1227            "assets": []
1228        });
1229        print_release_view(&release);
1230    }
1231
1232    #[test]
1233    fn print_release_view_prerelease() {
1234        let release = json!({
1235            "tag_name": "v2.0.0-rc1",
1236            "name": "Release Candidate 1",
1237            "body": "Pre-release testing",
1238            "html_url": "https://github.com/owner/repo/releases/tag/v2.0.0-rc1",
1239            "published_at": "2024-06-01T00:00:00Z",
1240            "author": { "login": "devbot" },
1241            "draft": false,
1242            "prerelease": true,
1243            "assets": null
1244        });
1245        print_release_view(&release);
1246    }
1247
1248    #[test]
1249    fn format_size_bytes() {
1250        assert_eq!(format_size(0), "0 B");
1251        assert_eq!(format_size(500), "500 B");
1252        assert_eq!(format_size(1024), "1.0 KB");
1253        assert_eq!(format_size(1_048_576), "1.0 MB");
1254        assert_eq!(format_size(1_073_741_824), "1.0 GB");
1255        assert_eq!(format_size(1_536), "1.5 KB");
1256    }
1257
1258    #[test]
1259    fn open_in_browser_does_not_panic() {
1260        // This should not panic even if no browser is available
1261        open_in_browser("https://example.com");
1262    }
1263
1264    #[test]
1265    fn detect_mime_type_common() {
1266        assert_eq!(detect_mime_type("app.zip"), "application/zip");
1267        assert_eq!(detect_mime_type("app.tar.gz"), "application/gzip");
1268        assert_eq!(detect_mime_type("app.dmg"), "application/x-apple-diskimage");
1269        assert_eq!(
1270            detect_mime_type("app.deb"),
1271            "application/vnd.debian.binary-package"
1272        );
1273        assert_eq!(detect_mime_type("app.msi"), "application/x-msi");
1274        assert_eq!(detect_mime_type("app.exe"), "application/x-msdownload");
1275        assert_eq!(detect_mime_type("app.txt"), "text/plain");
1276        assert_eq!(detect_mime_type("app.json"), "application/json");
1277        assert_eq!(detect_mime_type("app.png"), "image/png");
1278        assert_eq!(detect_mime_type("app.jpg"), "image/jpeg");
1279        assert_eq!(detect_mime_type("app.pdf"), "application/pdf");
1280    }
1281
1282    #[test]
1283    fn detect_mime_type_unknown() {
1284        assert_eq!(detect_mime_type("app.xyz"), "application/octet-stream");
1285        assert_eq!(detect_mime_type("app"), "application/octet-stream");
1286    }
1287
1288    #[test]
1289    fn glob_match_exact() {
1290        assert!(glob_match("app.zip", "app.zip"));
1291        assert!(!glob_match("app.zip", "app.tar.gz"));
1292    }
1293
1294    #[test]
1295    fn glob_match_star() {
1296        assert!(glob_match("*.zip", "app.zip"));
1297        assert!(glob_match("*.tar.gz", "app.tar.gz"));
1298        assert!(!glob_match("*.zip", "app.tar.gz"));
1299        assert!(glob_match("app-*", "app-linux-amd64"));
1300    }
1301
1302    #[test]
1303    fn glob_match_question() {
1304        assert!(glob_match("app.???", "app.zip"));
1305        assert!(!glob_match("app.???", "app.tar.gz"));
1306    }
1307
1308    #[test]
1309    fn glob_match_case_insensitive() {
1310        assert!(glob_match("*.ZIP", "app.zip"));
1311        assert!(glob_match("App-*", "app-linux"));
1312    }
1313}