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
|
|
@ -54,6 +54,7 @@ hive-forge timeline 42 # audit trail: closes, label changes
|
||||||
hive-forge attach-issue 42 /path/to/file # upload a file attachment to an issue; prints download URL
|
hive-forge attach-issue 42 /path/to/file # upload a file attachment to an issue; prints download URL
|
||||||
hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL
|
hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL
|
||||||
hive-forge attachment-get <uuid> # download an attachment; prints resolved path to stdout
|
hive-forge attachment-get <uuid> # download an attachment; prints resolved path to stdout
|
||||||
|
hive-forge artifact-get pr1ma-paper-pdf --run 51 # download a CI run's Actions artifact zip (run number from the run-page URL)
|
||||||
hive-forge subscription --watch # subscribe to repo notifications
|
hive-forge subscription --watch # subscribe to repo notifications
|
||||||
hive-forge subscription --unwatch # unsubscribe
|
hive-forge subscription --unwatch # unsubscribe
|
||||||
hive-forge -r internal/knowledge clone # clone with creds auto-injected
|
hive-forge -r internal/knowledge clone # clone with creds auto-injected
|
||||||
|
|
@ -127,5 +128,12 @@ plain comment show under `last comment`, not `reviews`.
|
||||||
URL ourselves).
|
URL ourselves).
|
||||||
- `attachment-get` saves to `/tmp/forge-attachment-{uuid}` by default
|
- `attachment-get` saves to `/tmp/forge-attachment-{uuid}` by default
|
||||||
and prints the resolved path. Pass `-o -` to stream to stdout.
|
and prints the resolved path. Pass `-o -` to stream to stdout.
|
||||||
|
- `artifact-get <name> --run <n>` downloads a CI Actions artifact. `<n>`
|
||||||
|
is the run number from the run-page URL (`/actions/runs/<n>`, which
|
||||||
|
`pr-status` surfaces as a CI context's target_url). Forgejo 15 serves
|
||||||
|
artifacts only via the web route, not REST, and that route is keyed by
|
||||||
|
the run's internal global id, so the verb translates the run number
|
||||||
|
first. Saves a zip to `/tmp/forge-artifact-<name>.zip` by default; pass
|
||||||
|
`-o -` to stream to stdout.
|
||||||
- Do NOT use raw `curl` for forge access -- the CLI handles auth,
|
- Do NOT use raw `curl` for forge access -- the CLI handles auth,
|
||||||
error checking, and output formatting.
|
error checking, and output formatting.
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,16 @@ impl Client {
|
||||||
format!("{}/attachments/{uuid}", self.base)
|
format!("{}/attachments/{uuid}", self.base)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a full URL for a base-relative *web* path (i.e. NOT under
|
||||||
|
/// `/api/v1/`). Used for endpoints Forgejo only serves through its
|
||||||
|
/// web UI rather than the REST API --- e.g. Actions artifact
|
||||||
|
/// downloads at `<base>/<owner>/<repo>/actions/runs/<n>/artifacts/<name>`.
|
||||||
|
/// `path` should start with `/`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn web_url(&self, path: &str) -> String {
|
||||||
|
format!("{}{path}", self.base)
|
||||||
|
}
|
||||||
|
|
||||||
/// GET a raw (non-API) URL and return the response body as bytes.
|
/// GET a raw (non-API) URL and return the response body as bytes.
|
||||||
/// The client's auth headers are still sent — Forgejo requires them
|
/// The client's auth headers are still sent — Forgejo requires them
|
||||||
/// for private attachment downloads. Uses the full URL as-is; the
|
/// for private attachment downloads. Uses the full URL as-is; the
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,11 @@ enum Verb {
|
||||||
/// Download an attachment by UUID or URL. Saves to a temp file and
|
/// Download an attachment by UUID or URL. Saves to a temp file and
|
||||||
/// prints the path (pass `-o -` to stream raw bytes to stdout).
|
/// prints the path (pass `-o -` to stream raw bytes to stdout).
|
||||||
AttachmentGet(verbs::attachment_get::Args),
|
AttachmentGet(verbs::attachment_get::Args),
|
||||||
|
/// Download a CI Actions artifact from a run (`<name> --run <n>`).
|
||||||
|
/// Forgejo serves artifacts only via the web route, not REST; the
|
||||||
|
/// caller supplies the run number + artifact name. Saves a zip
|
||||||
|
/// (or `-o -` to stream).
|
||||||
|
ArtifactGet(verbs::artifact_get::Args),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
@ -156,5 +161,6 @@ fn main() -> Result<()> {
|
||||||
Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a),
|
Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a),
|
||||||
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
|
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
|
||||||
Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a),
|
Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a),
|
||||||
|
Verb::ArtifactGet(a) => verbs::artifact_get::run(&client, a),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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(())
|
||||||
|
}
|
||||||
|
|
@ -99,23 +99,23 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
if let Some(u) = args.assignee.as_deref()
|
if let Some(u) = args.assignee.as_deref()
|
||||||
&& !u.is_empty()
|
&& !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()
|
if let Some(u) = args.author.as_deref()
|
||||||
&& !u.is_empty()
|
&& !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()
|
if let Some(u) = args.mention.as_deref()
|
||||||
&& !u.is_empty()
|
&& !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() {
|
if !args.labels.is_empty() {
|
||||||
// Encode each label individually so a comma INSIDE a label
|
// Encode each label individually so a comma INSIDE a label
|
||||||
// (rare but legal) gets escaped while the field separator
|
// (rare but legal) gets escaped while the field separator
|
||||||
// stays a literal comma the forge will parse as N labels.
|
// 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();
|
write!(path, "&labels={}", encoded.join(",")).unwrap();
|
||||||
}
|
}
|
||||||
let resp = client.get_json(&path)?;
|
let resp = client.get_json(&path)?;
|
||||||
|
|
@ -134,23 +134,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
Ok(())
|
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.
|
/// Render one issue/PR as a single `#NNN [author] title` line.
|
||||||
/// Defensive: missing fields drop to placeholders so a partial
|
/// Defensive: missing fields drop to placeholders so a partial
|
||||||
/// response from a future API change still produces readable output
|
/// 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::Closed.api_value(), "closed");
|
||||||
assert_eq!(State::All.api_value(), "all");
|
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
//! Splitting one verb per module keeps each handler small and avoids
|
//! Splitting one verb per module keeps each handler small and avoids
|
||||||
//! the bash script's monolithic `case` statement.
|
//! the bash script's monolithic `case` statement.
|
||||||
|
|
||||||
|
pub mod artifact_get;
|
||||||
pub mod assign;
|
pub mod assign;
|
||||||
pub mod attach;
|
pub mod attach;
|
||||||
pub mod attachment_get;
|
pub mod attachment_get;
|
||||||
|
|
@ -33,6 +34,8 @@ pub mod timeline;
|
||||||
pub mod tree_sha;
|
pub mod tree_sha;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|
@ -46,6 +49,25 @@ pub(crate) fn print_json(v: &Value) -> Result<()> {
|
||||||
Ok(())
|
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
|
/// Latest non-comment review verdict per reviewer on a PR, as
|
||||||
/// `(login, state)`. Reviews come oldest-first, so a later verdict from
|
/// `(login, state)`. Reviews come oldest-first, so a later verdict from
|
||||||
/// the same user supersedes an earlier one; `COMMENT` / `PENDING`
|
/// the same user supersedes an earlier one; `COMMENT` / `PENDING`
|
||||||
|
|
@ -80,3 +102,28 @@ pub(crate) fn latest_reviews(
|
||||||
}
|
}
|
||||||
Ok(latest)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue