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:
parent
801b886a4c
commit
6490fc422e
6 changed files with 184 additions and 41 deletions
109
hive-forge/src/verbs/artifact_get.rs
Normal file
109
hive-forge/src/verbs/artifact_get.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Reference in a new issue