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

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