diff --git a/docs/tools/forge.md b/docs/tools/forge.md index c86382b6..18444951 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -55,6 +55,7 @@ hive-forge attach-issue 42 /path/to/file # upload a file attachment to an iss hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL hive-forge attachment-get # 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 ci-log --run 51 # print a CI run's job step logs (run number from the run-page URL); --job i / --step i to narrow hive-forge subscription --watch # subscribe to repo notifications hive-forge subscription --unwatch # unsubscribe hive-forge -r internal/knowledge clone # clone with creds auto-injected @@ -135,5 +136,15 @@ plain comment show under `last comment`, not `reviews`. the run's internal global id, so the verb translates the run number first. Saves a zip to `/tmp/forge-artifact-.zip` by default; pass `-o -` to stream to stdout. +- `ci-log --run [--job i] [--step i]` prints a CI run's job step + logs. `` is the run number from the run-page URL (same value + `artifact-get` takes; `pr-status` surfaces it as a CI context's + target_url). Forgejo exposes no REST endpoint for job logs, so the + verb drives the web run-view streamer the run page polls. `--job` + selects the job within the run (0-based, default 0); `--step` narrows + to one step. `act_runner` garbage-collects completed-run logs, so this + is reliable for live + recently-finished runs; when logs are gone the + verb says so rather than printing nothing. `--json` dumps the raw + run-view response. - Do NOT use raw `curl` for forge access -- the CLI handles auth, error checking, and output formatting. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 9534d6fa..16a81223 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -283,6 +283,28 @@ impl Client { format!("{}{path}", self.base) } + /// POST a JSON body to a base-relative *web* path (NOT under + /// `/api/v1/`) and decode the JSON response. Used for endpoints + /// Forgejo only serves through its web router — e.g. the Actions + /// run-view log streamer at + /// `///actions/runs//jobs/`. The + /// agent's `Authorization: token` header is sent as usual; the + /// web router accepts a token-authed doer and, because the handler + /// consumes a JSON body rather than a CSRF-bound HTML form, no + /// `_csrf` token is required (same auth path `get_bytes_raw` uses + /// for the web artifact-download route). `path` should start `/`. + pub fn post_json_web(&self, path: &str, body: &B) -> Result { + let url = self.web_url(path); + let resp = self + .http + .post(&url) + .header(CONTENT_TYPE, "application/json") + .json(body) + .send() + .context("POST")?; + decode_json(resp, &format!("POST {url}")) + } + /// GET a raw (non-API) URL and return the response body as bytes. /// The client's auth headers are still sent — Forgejo requires them /// for private attachment downloads. Uses the full URL as-is; the diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 3a1e3665..fbd4fc74 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -130,6 +130,10 @@ enum Verb { /// caller supplies the run number + artifact name. Saves a zip /// (or `-o -` to stream). ArtifactGet(verbs::artifact_get::Args), + /// Print a CI Actions run's job step logs (`--run [--job i] + /// [--step i]`). Uses Forgejo's web run-view streamer (no REST + /// endpoint exists); reliable for live + recently-finished runs. + CiLog(verbs::ci_log::Args), } fn main() -> Result<()> { @@ -167,5 +171,6 @@ fn main() -> Result<()> { Verb::AttachComment(a) => verbs::attach::run_comment(&client, a), Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a), Verb::ArtifactGet(a) => verbs::artifact_get::run(&client, a), + Verb::CiLog(a) => verbs::ci_log::run(&client, a), } } diff --git a/hive-forge/src/verbs/ci_log.rs b/hive-forge/src/verbs/ci_log.rs new file mode 100644 index 00000000..c5c48fd3 --- /dev/null +++ b/hive-forge/src/verbs/ci_log.rs @@ -0,0 +1,177 @@ +//! `ci-log --run [--job ] [--step ]` — print a CI +//! Actions run's job step logs. +//! +//! Forgejo exposes no REST endpoint for Actions job logs; the only path +//! is the web run-view streamer the run page polls, +//! `POST ///actions/runs//jobs/` with a +//! `{"logCursors":[{"step":N,"cursor":C,"expanded":bool}]}` body. Unlike +//! the artifact-download route (which keys off the run's GLOBAL id), this +//! route uses the per-repo run NUMBER — the `runs/` the UI shows and +//! `pr-status` surfaces as a CI context `target_url` — so the caller's +//! `--run` value is used directly, no id translation. +//! +//! Protocol: POST once with an empty cursor set to fetch the run state +//! and enumerate the job's steps, then POST again requesting each step's +//! log from cursor 0 (`expanded: true`) and print the assembled lines. +//! +//! Caveat: `act_runner` garbage-collects step logs for completed runs, so +//! a run that finished a while ago may return no log lines. The verb is +//! reliable for in-progress and recently-finished runs; when no lines +//! come back it says so rather than printing nothing. + +use anyhow::{Result, bail}; +use clap::Args as ClapArgs; +use serde_json::{Value, json}; + +use crate::client::Client; + +#[derive(ClapArgs)] +pub struct Args { + /// Workflow run number — the `runs/` in the run-page URL, which + /// `pr-status` surfaces as a CI context's `target_url`. + #[arg(long)] + run: u64, + /// Job index within the run (0-based; default 0 — the first job). + #[arg(long, default_value_t = 0)] + job: u64, + /// Print only this step's log (0-based). Omit to print every step. + #[arg(long)] + step: Option, +} + +/// Extract the step list from a run-view response +/// (`state.currentJob.steps`). +fn steps_of(view: &Value) -> Vec { + view.get("state") + .and_then(|s| s.get("currentJob")) + .and_then(|j| j.get("steps")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +/// Best-effort display name for a step. Forgejo's `ViewJobStep` carries +/// the name in `summary`; fall back to `name` then a generic label so a +/// schema tweak doesn't blank the header. +fn step_name(step: Option<&Value>) -> String { + step.and_then(|s| { + s.get("summary") + .or_else(|| s.get("name")) + .and_then(Value::as_str) + }) + .unwrap_or("step") + .to_owned() +} + +/// # Errors +/// +/// Returns an error if the run/job can't be fetched (network, or a +/// non-2xx such as `404` for an unknown run), if the run has no steps, +/// or if no log lines are returned (e.g. logs already garbage-collected). +pub fn run(client: &Client, args: Args) -> Result<()> { + let repo = client.repo(); + let path = format!("/{repo}/actions/runs/{}/jobs/{}", args.run, args.job); + + // 1. Discover the job's steps (state is returned regardless of cursors). + let view = client.post_json_web(&path, &json!({ "logCursors": [] }))?; + let steps = steps_of(&view); + if steps.is_empty() { + bail!( + "run #{} job {} has no steps — run/job not found, or logs were \ + garbage-collected (act_runner prunes completed-run logs). Try a \ + live or recently-finished run.", + args.run, + args.job + ); + } + + // 2. Request the chosen step(s) from cursor 0, fully expanded. + let want: Vec = match args.step { + Some(s) if s >= steps.len() => bail!( + "step {s} out of range — run #{} job {} has {} step(s) (0..{})", + args.run, + args.job, + steps.len(), + steps.len() - 1 + ), + Some(s) => vec![s], + None => (0..steps.len()).collect(), + }; + let cursors: Vec = want + .iter() + .map(|&s| json!({ "step": s, "cursor": 0, "expanded": true })) + .collect(); + let resp = client.post_json_web(&path, &json!({ "logCursors": cursors }))?; + + if client.json_mode() { + return crate::verbs::print_json(&resp); + } + + // 3. Human output: a header per step, then its lines in order. + let steps_log = resp + .get("logs") + .and_then(|l| l.get("stepsLog")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if steps_log.is_empty() { + bail!( + "no log lines returned for run #{} job {} — logs were likely \ + garbage-collected (act_runner prunes completed-run logs). ci-log \ + is reliable on live and recently-finished runs.", + args.run, + args.job + ); + } + for sl in &steps_log { + let idx = sl.get("step").and_then(Value::as_u64).unwrap_or_default() as usize; + println!("=== step {idx}: {} ===", step_name(steps.get(idx))); + if let Some(lines) = sl.get("lines").and_then(Value::as_array) { + for line in lines { + println!( + "{}", + line.get("message").and_then(Value::as_str).unwrap_or("") + ); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn steps_of_reads_current_job_steps() { + let view = json!({ + "state": { "currentJob": { "steps": [ + { "summary": "Set up job" }, + { "summary": "Run tests" } + ] } } + }); + let steps = steps_of(&view); + assert_eq!(steps.len(), 2); + assert_eq!(step_name(steps.first()), "Set up job"); + assert_eq!(step_name(steps.get(1)), "Run tests"); + } + + #[test] + fn steps_of_missing_is_empty() { + assert!(steps_of(&json!({})).is_empty()); + assert!(steps_of(&json!({ "state": {} })).is_empty()); + } + + #[test] + fn step_name_falls_back() { + assert_eq!(step_name(Some(&json!({ "name": "compile" }))), "compile"); + assert_eq!(step_name(Some(&json!({}))), "step"); + assert_eq!(step_name(None), "step"); + // `summary` wins over `name` when both are present. + assert_eq!( + step_name(Some(&json!({ "summary": "a", "name": "b" }))), + "a" + ); + } +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 90b8fc4c..843fef70 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -8,6 +8,7 @@ pub mod assign; pub mod attach; pub mod attachment_get; pub mod branches; +pub mod ci_log; pub mod clone; pub mod close; pub mod comment;