hyperhive/hive-forge/src/verbs/artifact_get.rs

110 lines
4.2 KiB
Rust

//! `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 forgejo_api::structs::ListActionRunsQuery;
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<i64> {
const PER_PAGE: u32 = 50;
const MAX_PAGES: u32 = 40;
let (owner, name) = client.owner_repo()?;
for page in 1..=MAX_PAGES {
let body = client
.api()
.list_action_runs(owner, name, ListActionRunsQuery::default())
.page(page)
.page_size(PER_PAGE)
.send()?;
let runs = body.workflow_runs.unwrap_or_default();
if runs.is_empty() {
break;
}
for run in &runs {
let tail = run
.html_url
.as_ref()
.and_then(|u| u.as_str().rsplit('/').next())
.and_then(|s| s.parse::<u64>().ok());
if tail == Some(run_number)
&& let Some(id) = run.id
{
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(())
}