hive-forge: add ci-log verb for actions job step logs (closes #1674)

This commit is contained in:
damocles 2026-06-16 11:34:31 +02:00
commit 2223af1b21
5 changed files with 216 additions and 0 deletions

View file

@ -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 <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 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-<name>.zip` by default; pass
`-o -` to stream to stdout.
- `ci-log --run <n> [--job i] [--step i]` prints a CI run's job step
logs. `<n>` 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.

View file

@ -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
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. 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<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
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

View file

@ -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 <n> [--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),
}
}

View file

@ -0,0 +1,177 @@
//! `ci-log --run <run-number> [--job <idx>] [--step <idx>]` — 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 <base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>` 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/<n>` 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/<n>` 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<usize>,
}
/// Extract the step list from a run-view response
/// (`state.currentJob.steps`).
fn steps_of(view: &Value) -> Vec<Value> {
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<usize> = 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<Value> = 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"
);
}
}

View file

@ -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;