feat(#1315): hive-forge attachment-get verb - download attachments by uuid or url

This commit is contained in:
damocles 2026-06-04 21:35:21 +02:00 committed by mara
commit 98f96e5435
6 changed files with 118 additions and 3 deletions

View file

@ -197,6 +197,26 @@ impl Client {
Ok(())
}
/// Build the full URL for a Forgejo attachment by UUID.
/// Attachments live at `<base>/attachments/<uuid>`, NOT under
/// `/api/v1/`, so this uses `self.base` directly.
#[must_use]
pub fn attachment_url(&self, uuid: &str) -> String {
format!("{}/attachments/{uuid}", self.base)
}
/// GET a raw (non-API) URL and return the response body as bytes.
/// The client's auth headers are still sent — Forgejo requires them
/// for private attachment downloads. Uses the full URL as-is; the
/// caller is responsible for constructing it (see `attachment_url`).
pub fn get_bytes_raw(&self, url: &str) -> Result<Vec<u8>> {
let resp = self.http.get(url).send().context("GET")?;
let resp = check_status(resp, &format!("GET {url}"))?;
resp.bytes()
.map(|b| b.to_vec())
.with_context(|| format!("read bytes for GET {url}"))
}
/// POST a multipart file upload, returning the parsed response.
/// Used by `attach-issue` / `attach-comment`.
pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result<Value> {

View file

@ -99,6 +99,9 @@ enum Verb {
AttachIssue(verbs::attach::IssueArgs),
/// Upload a file as an attachment to a comment.
AttachComment(verbs::attach::CommentArgs),
/// Download an attachment by UUID or URL. Saves to a temp file and
/// prints the path (pass `-o -` to stream raw bytes to stdout).
AttachmentGet(verbs::attachment_get::Args),
}
fn main() -> Result<()> {
@ -129,5 +132,6 @@ fn main() -> Result<()> {
Verb::Timeline(a) => verbs::timeline::run(&client, a),
Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a),
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a),
}
}

View file

@ -0,0 +1,90 @@
//! `attachment-get <uuid-or-url> [-o <path>]` — download a Forgejo
//! attachment to a local file (or stdout with `-o -`). Accepts either
//! the bare attachment UUID or any URL form that ends with
//! `/attachments/<uuid>` (as it appears in comment markdown).
//!
//! Default output path: `/tmp/forge-attachment-<uuid>`. Prints the
//! resolved path to stderr so callers can locate it easily.
use std::io::Write as _;
use std::path::PathBuf;
use anyhow::{Result, bail};
use clap::Args as ClapArgs;
use crate::client::Client;
#[derive(ClapArgs)]
pub struct Args {
/// Attachment UUID or URL. Accepts a bare UUID (`abc-123-...`),
/// a root-relative path (`/attachments/abc-123-...`), or a full
/// URL (`http://localhost:3000/attachments/abc-123-...`).
attachment: String,
/// Output path. Defaults to `/tmp/forge-attachment-<uuid>`.
/// Pass `-` to write raw bytes to stdout (e.g. for piping to an
/// image viewer).
#[arg(short = 'o', long)]
output: Option<String>,
}
/// # Errors
///
/// Returns an error if the attachment UUID cannot be extracted from
/// the input, if the download fails (network, 4xx/5xx), or if the
/// output path cannot be written.
pub fn run(client: &Client, args: Args) -> Result<()> {
let uuid = extract_uuid(&args.attachment)?;
let url = client.attachment_url(&uuid);
let bytes = client.get_bytes_raw(&url)?;
match args.output.as_deref() {
Some("-") => {
std::io::stdout()
.write_all(&bytes)
.map_err(|e| anyhow::anyhow!("write stdout: {e}"))?;
}
Some(path) => {
let p = PathBuf::from(path);
std::fs::write(&p, &bytes)
.map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?;
println!("{}", p.display());
}
None => {
let p = PathBuf::from(format!("/tmp/forge-attachment-{uuid}"));
std::fs::write(&p, &bytes)
.map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?;
println!("{}", p.display());
}
}
Ok(())
}
/// Extract the bare UUID from whatever the caller passes.
///
/// Accepted forms:
/// - bare UUID: `abc-123-...`
/// - root-relative path: `/attachments/abc-123-...`
/// - full loopback URL: `http://localhost:3000/attachments/abc-123-...`
/// - any URL ending with `/attachments/<uuid>`
///
/// # Errors
///
/// Returns an error if the input contains no path segment after
/// parsing (e.g. an empty string).
fn extract_uuid(input: &str) -> Result<String> {
// If it contains a slash, treat everything after the last slash as
// the UUID. This handles all URL/path forms.
let uuid = if input.contains('/') {
input
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("hive-forge attachment-get: cannot extract UUID from {input:?}"))?
} else {
input
};
if uuid.is_empty() {
bail!("hive-forge attachment-get: empty UUID in {input:?}");
}
Ok(uuid.to_owned())
}

View file

@ -5,6 +5,7 @@
pub mod assign;
pub mod attach;
pub mod attachment_get;
pub mod branches;
pub mod close;
pub mod comment;