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

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