diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 2bf2ee44..c86382b6 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -54,6 +54,7 @@ hive-forge timeline 42 # audit trail: closes, label changes hive-forge attach-issue 42 /path/to/file # upload a file attachment to an issue; prints download URL hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL hive-forge attachment-get # download an attachment; prints resolved path to stdout +hive-forge artifact-get pr1ma-paper-pdf --run 51 # download a CI run's Actions artifact zip (run number from the run-page URL) hive-forge subscription --watch # subscribe to repo notifications hive-forge subscription --unwatch # unsubscribe hive-forge -r internal/knowledge clone # clone with creds auto-injected @@ -127,5 +128,12 @@ plain comment show under `last comment`, not `reviews`. URL ourselves). - `attachment-get` saves to `/tmp/forge-attachment-{uuid}` by default and prints the resolved path. Pass `-o -` to stream to stdout. +- `artifact-get --run ` downloads a CI Actions artifact. `` + is the run number from the run-page URL (`/actions/runs/`, which + `pr-status` surfaces as a CI context's target_url). Forgejo 15 serves + artifacts only via the web route, not REST, and that route is keyed by + the run's internal global id, so the verb translates the run number + first. Saves a zip to `/tmp/forge-artifact-.zip` by default; pass + `-o -` to stream to stdout. - Do NOT use raw `curl` for forge access -- the CLI handles auth, error checking, and output formatting. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index ddadac19..9534d6fa 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -273,6 +273,16 @@ impl Client { format!("{}/attachments/{uuid}", self.base) } + /// Build a full URL for a base-relative *web* path (i.e. NOT under + /// `/api/v1/`). Used for endpoints Forgejo only serves through its + /// web UI rather than the REST API --- e.g. Actions artifact + /// downloads at `///actions/runs//artifacts/`. + /// `path` should start with `/`. + #[must_use] + pub fn web_url(&self, path: &str) -> String { + format!("{}{path}", 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 diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index b7524fc4..0ff55cb6 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -120,6 +120,11 @@ enum Verb { /// 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), + /// Download a CI Actions artifact from a run (` --run `). + /// Forgejo serves artifacts only via the web route, not REST; the + /// caller supplies the run number + artifact name. Saves a zip + /// (or `-o -` to stream). + ArtifactGet(verbs::artifact_get::Args), } fn main() -> Result<()> { @@ -156,5 +161,6 @@ fn main() -> Result<()> { 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), + Verb::ArtifactGet(a) => verbs::artifact_get::run(&client, a), } } diff --git a/hive-forge/src/verbs/artifact_get.rs b/hive-forge/src/verbs/artifact_get.rs new file mode 100644 index 00000000..b4b28f2d --- /dev/null +++ b/hive-forge/src/verbs/artifact_get.rs @@ -0,0 +1,109 @@ +//! `artifact-get --run [-o ]` — download a CI +//! Actions artifact from a workflow run to a local file (or stdout with +//! `-o -`). +//! +//! Forgejo 15 exposes no REST endpoint to download an Actions artifact; +//! the only path is the web UI's download route, +//! `///actions/runs//artifacts/`. That +//! route is keyed by the run's GLOBAL id, **not** the per-repo run number +//! the UI shows in run-page URLs (`/actions/runs/51`) and that `pr-status` +//! surfaces as a CI context's `target_url`. The REST artifacts *list* +//! route, confusingly, keys off the run number instead — so the two can't +//! be chained directly. We therefore translate the caller's run number +//! into the run's global id via the REST runs list +//! (`/repos//actions/runs`, matching each run's `html_url` tail), +//! then GET the web download route with the agent's forge token. The +//! artifact is served as a zip; the default output path reflects that. +//! +//! Use case: an agent pulling a CI-built artifact (e.g. a paper PDF) into +//! `/shared` for delivery or review without host access. + +use std::io::Write as _; +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use clap::Args as ClapArgs; +use serde_json::Value; + +use crate::client::Client; + +#[derive(ClapArgs)] +pub struct Args { + /// Artifact name, as shown on the run page (e.g. `pr1ma-paper-pdf`). + name: String, + /// Workflow run number — the `runs/` in the run-page URL, which + /// `pr-status` surfaces as a CI context's `target_url`. (This is the + /// per-repo run number, not the global run id; the verb translates.) + #[arg(long)] + run: u64, + /// Output path. Defaults to `/tmp/forge-artifact-.zip` (Forgejo + /// serves artifacts zipped). Pass `-` to stream raw bytes to stdout. + #[arg(short = 'o', long)] + output: Option, +} + +/// Pages over the REST runs list (newest-first) to find the run whose +/// run-page `html_url` ends in `/runs/`, returning its global +/// run id — the identifier the web artifact-download route requires. +fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result { + const PER_PAGE: u32 = 50; + const MAX_PAGES: u32 = 40; + for page in 1..=MAX_PAGES { + let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}"); + let body = client.get_json(&path)?; + let runs = body + .get("workflow_runs") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if runs.is_empty() { + break; + } + for run in &runs { + let tail = run + .get("html_url") + .and_then(Value::as_str) + .and_then(|u| u.rsplit('/').next()) + .and_then(|s| s.parse::().ok()); + if tail == Some(run_number) + && let Some(id) = run.get("id").and_then(Value::as_u64) + { + return Ok(id); + } + } + } + bail!("run #{run_number} not found in {repo} (no matching workflow run)"); +} + +/// # Errors +/// +/// Returns an error if the run number can't be resolved to a run, if the +/// download fails (network, or a non-2xx status such as `404` for an +/// unknown artifact name), or if the output path can't be written. +pub fn run(client: &Client, args: Args) -> Result<()> { + let repo = client.repo(); + let run_id = resolve_run_id(client, repo, args.run)?; + let url = client.web_url(&format!( + "/{repo}/actions/runs/{run_id}/artifacts/{}", + super::pct_encode(&args.name) + )); + 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}"))?; + } + path => { + let p = path.map_or_else( + || PathBuf::from(format!("/tmp/forge-artifact-{}.zip", args.name)), + PathBuf::from, + ); + std::fs::write(&p, &bytes) + .map_err(|e| anyhow::anyhow!("write {}: {e}", p.display()))?; + println!("{}", p.display()); + } + } + Ok(()) +} diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs index c8764a10..00c82870 100644 --- a/hive-forge/src/verbs/list.rs +++ b/hive-forge/src/verbs/list.rs @@ -99,23 +99,23 @@ pub fn run(client: &Client, args: Args) -> Result<()> { if let Some(u) = args.assignee.as_deref() && !u.is_empty() { - write!(path, "&assigned_by={}", pct_encode(u)).unwrap(); + write!(path, "&assigned_by={}", super::pct_encode(u)).unwrap(); } if let Some(u) = args.author.as_deref() && !u.is_empty() { - write!(path, "&created_by={}", pct_encode(u)).unwrap(); + write!(path, "&created_by={}", super::pct_encode(u)).unwrap(); } if let Some(u) = args.mention.as_deref() && !u.is_empty() { - write!(path, "&mentioned_by={}", pct_encode(u)).unwrap(); + write!(path, "&mentioned_by={}", super::pct_encode(u)).unwrap(); } if !args.labels.is_empty() { // Encode each label individually so a comma INSIDE a label // (rare but legal) gets escaped while the field separator // stays a literal comma the forge will parse as N labels. - let encoded: Vec = args.labels.iter().map(|l| pct_encode(l)).collect(); + let encoded: Vec = args.labels.iter().map(|l| super::pct_encode(l)).collect(); write!(path, "&labels={}", encoded.join(",")).unwrap(); } let resp = client.get_json(&path)?; @@ -134,23 +134,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> { Ok(()) } -/// Minimal RFC 3986 unreserved-set percent encoder. Covers the -/// subset of characters that show up in usernames + label names -/// (spaces in labels are the realistic non-ASCII case) without -/// pulling in a fresh workspace dep. Username regex is gitea-style -/// `[a-zA-Z0-9_-]` so the no-op fast path covers all of them. -fn pct_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { - out.push(b as char); - } else { - write!(out, "%{b:02X}").unwrap(); - } - } - out -} - /// Render one issue/PR as a single `#NNN [author] title` line. /// Defensive: missing fields drop to placeholders so a partial /// response from a future API change still produces readable output @@ -192,24 +175,4 @@ mod tests { assert_eq!(State::Closed.api_value(), "closed"); assert_eq!(State::All.api_value(), "all"); } - - #[test] - fn pct_encode_passes_unreserved_through() { - // Usernames + plain label names round-trip verbatim — no - // performance regression on the common case. - assert_eq!(pct_encode("damocles"), "damocles"); - assert_eq!(pct_encode("area-ops"), "area-ops"); - assert_eq!(pct_encode("area_ops"), "area_ops"); - } - - #[test] - fn pct_encode_escapes_spaces_and_specials() { - // Forgejo labels can contain spaces ("good first issue" is a - // canonical example); the comma separator gets encoded too - // when it appears INSIDE a label name (we re-add the joined - // form unencoded above as the field separator). - assert_eq!(pct_encode("good first issue"), "good%20first%20issue"); - assert_eq!(pct_encode("a&b"), "a%26b"); - assert_eq!(pct_encode("a/b"), "a%2Fb"); - } } diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 4f1f3a79..90b8fc4c 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -3,6 +3,7 @@ //! Splitting one verb per module keeps each handler small and avoids //! the bash script's monolithic `case` statement. +pub mod artifact_get; pub mod assign; pub mod attach; pub mod attachment_get; @@ -33,6 +34,8 @@ pub mod timeline; pub mod tree_sha; pub mod view; +use std::fmt::Write as _; + use anyhow::Result; use serde_json::Value; @@ -46,6 +49,25 @@ pub(crate) fn print_json(v: &Value) -> Result<()> { Ok(()) } +/// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of +/// characters that show up in the values we splice into request paths — +/// usernames, label names, artifact names — without pulling in a fresh +/// workspace dep. Unreserved bytes (`[A-Za-z0-9-._~]`) pass through, so +/// the common identifier case is a no-op; everything else is `%XX`-escaped. +/// Shared by `list` (query-string filters) and `artifact-get` (the +/// artifact-name path segment). +pub(crate) fn pct_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(b as char); + } else { + write!(out, "%{b:02X}").unwrap(); + } + } + out +} + /// Latest non-comment review verdict per reviewer on a PR, as /// `(login, state)`. Reviews come oldest-first, so a later verdict from /// the same user supersedes an earlier one; `COMMENT` / `PENDING` @@ -80,3 +102,28 @@ pub(crate) fn latest_reviews( } Ok(latest) } + +#[cfg(test)] +mod tests { + use super::pct_encode; + + #[test] + fn pct_encode_passes_unreserved_through() { + // Usernames + plain label/artifact names round-trip verbatim — + // no performance regression on the common case. + assert_eq!(pct_encode("damocles"), "damocles"); + assert_eq!(pct_encode("area-ops"), "area-ops"); + assert_eq!(pct_encode("area_ops"), "area_ops"); + assert_eq!(pct_encode("pr1ma-paper-pdf"), "pr1ma-paper-pdf"); + } + + #[test] + fn pct_encode_escapes_reserved() { + // Forgejo labels can contain spaces ("good first issue" is the + // canonical example); `&` / `/` in any spliced value must escape + // so they can't break out of the path/query segment. + assert_eq!(pct_encode("good first issue"), "good%20first%20issue"); + assert_eq!(pct_encode("x&y"), "x%26y"); + assert_eq!(pct_encode("a/b"), "a%2Fb"); + } +}