//! `artifact-get --run [-o ]` — 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, //! `///actions/runs//artifacts/`. 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//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/` 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-.zip` (Forgejo /// serves artifacts zipped). Pass `-` to stream raw bytes to stdout. #[arg(short = 'o', long)] output: Option, } /// Pages over the REST runs list (newest-first) to find the run whose /// run-page `html_url` ends in `/runs/`, 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 { 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::().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(()) }