feat(#1315): hive-forge attachment-get verb - download attachments by uuid or url
This commit is contained in:
parent
86165f07c8
commit
98f96e5435
6 changed files with 118 additions and 3 deletions
|
|
@ -288,8 +288,8 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
|||
issue-edit, pr-create, pr-reviews, assign,
|
||||
close, labels, list, milestone, branches,
|
||||
tree-sha, diff, subscription, attach-issue,
|
||||
attach-comment, lint). Replaces the 600-line
|
||||
hive-forge-tools.nix bash script.
|
||||
attach-comment, attachment-get, lint). Replaces the
|
||||
600-line hive-forge-tools.nix bash script.
|
||||
|
||||
hive-matrix-mcp/ per-agent matrix-sdk integration.
|
||||
src/main.rs `hive-matrix-daemon` binary entry — long-running
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across
|
|||
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000/<org>/<repo>.git` (credentials are pre-configured).
|
||||
|
||||
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `list`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `list`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`, `attachment-get`. `lint <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. To download an attachment: `hive-forge attachment-get <uuid-or-url> [-o <path>]` — saves to `/tmp/forge-attachment-<uuid>` by default and prints the path; pass `-o -` to stream to stdout. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — coordinate through shared space or a common parent.
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
90
hive-forge/src/verbs/attachment_get.rs
Normal file
90
hive-forge/src/verbs/attachment_get.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
pub mod assign;
|
||||
pub mod attach;
|
||||
pub mod attachment_get;
|
||||
pub mod branches;
|
||||
pub mod close;
|
||||
pub mod comment;
|
||||
|
|
|
|||
Loading…
Reference in a new issue