196 lines
6.9 KiB
Rust
196 lines
6.9 KiB
Rust
//! `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).
|
|
// A 404 (run/job gone) or 500 ("task ... resource does not exist", which
|
|
// the forge returns once act_runner has pruned a completed run's task
|
|
// record) surfaces here as an Err — translate it into the same
|
|
// logs-unavailable guidance rather than leaking the raw HTTP error.
|
|
let view = match client.post_json_web(&path, &json!({ "logCursors": [] })) {
|
|
Ok(v) => v,
|
|
Err(e) => bail!(
|
|
"run #{} job {} unavailable — the run/job may not exist, or its logs were \
|
|
garbage-collected (act_runner prunes completed-run task records; ci-log \
|
|
is reliable on live + recently-finished runs). Underlying: {e}",
|
|
args.run,
|
|
args.job
|
|
),
|
|
};
|
|
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)
|
|
.and_then(|n| usize::try_from(n).ok())
|
|
.unwrap_or_default();
|
|
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::*;
|
|
|
|
#[test]
|
|
fn steps_of_reads_current_job_steps() {
|
|
let view = serde_json::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(&serde_json::json!({})).is_empty());
|
|
assert!(steps_of(&serde_json::json!({ "state": {} })).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn step_name_falls_back() {
|
|
assert_eq!(
|
|
step_name(Some(&serde_json::json!({ "name": "compile" }))),
|
|
"compile"
|
|
);
|
|
assert_eq!(step_name(Some(&serde_json::json!({}))), "step");
|
|
assert_eq!(step_name(None), "step");
|
|
// `summary` wins over `name` when both are present.
|
|
assert_eq!(
|
|
step_name(Some(&serde_json::json!({ "summary": "a", "name": "b" }))),
|
|
"a"
|
|
);
|
|
}
|
|
}
|