feat(hive-forge): add artifact-get verb to download CI run artifacts

Forgejo 15 has no REST endpoint to download an Actions artifact — the
only path is the web UI download route, which is keyed by the run's
global id rather than the per-repo run number shown in run-page URLs.
The REST artifacts list route keys off the run number instead, so the
two can't be chained directly.

artifact-get takes the run number (what pr-status surfaces as a CI
context target_url), translates it to the global run id via the REST
runs list by matching each run's html_url tail, then GETs the web
download route with the agent's forge token. Saves the artifact zip to
a path (default /tmp/forge-artifact-<name>.zip) or streams to stdout
with -o -.

The artifact name is percent-encoded into the path. The encoder that
list already used for query-string filters is promoted to a shared
verbs::pct_encode helper so both call sites stay in sync.

Lets an agent pull a CI-built artifact (e.g. a paper PDF) into /shared
without host access.
This commit is contained in:
atlas 2026-06-15 17:29:07 +02:00 committed by mara
commit 6490fc422e
6 changed files with 184 additions and 41 deletions

View file

@ -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 <uuid> # 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 <name> --run <n>` downloads a CI Actions artifact. `<n>`
is the run number from the run-page URL (`/actions/runs/<n>`, 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-<name>.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.

View file

@ -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 `<base>/<owner>/<repo>/actions/runs/<n>/artifacts/<name>`.
/// `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

View file

@ -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 (`<name> --run <n>`).
/// 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),
}
}

View file

@ -0,0 +1,109 @@
//! `artifact-get <name> --run <run-number> [-o <path>]` — 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,
//! `<base>/<owner>/<repo>/actions/runs/<run-id>/artifacts/<name>`. 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/<repo>/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/<n>` 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-<name>.zip` (Forgejo
/// serves artifacts zipped). Pass `-` to stream raw bytes to stdout.
#[arg(short = 'o', long)]
output: Option<String>,
}
/// Pages over the REST runs list (newest-first) to find the run whose
/// run-page `html_url` ends in `/runs/<run-number>`, 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<u64> {
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::<u64>().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(())
}

View file

@ -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<String> = args.labels.iter().map(|l| pct_encode(l)).collect();
let encoded: Vec<String> = 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");
}
}

View file

@ -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");
}
}