Drop implementation detail from the clap arg help (the `<verb> --help` surface) — which API/endpoint, page-count math, persisted-vs-streamer log-source internals, refspec shapes — keeping only what/when-to-use for each flag. Module `//!` docs (dev-facing, not shown by `--help`) left intact.
286 lines
10 KiB
Rust
286 lines
10 KiB
Rust
//! `ci-log --run <run-number> [--job <idx>] [--step <idx>]` — print a CI
|
|
//! Actions run's job step logs.
|
|
//!
|
|
//! Two log sources, in **completeness order** (not discovery order):
|
|
//!
|
|
//! 1. **Durable persisted-log download** — tried FIRST. Once a job has
|
|
//! finished, Forgejo has the complete flat log on disk; fetched via the
|
|
//! web route the run page's "view raw logs" link uses,
|
|
//! `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>/attempt/<n>/logs`.
|
|
//! Flat (no per-step framing, so `--step` isn't honored here) and only
|
|
//! unavailable while the job hasn't finished yet.
|
|
//! 2. **Live run-view streamer** — the run page's own polling endpoint.
|
|
//! Rich per-step framing, but each call is a single snapshot of the live
|
|
//! `act_runner` task record: fine for a still-running job (nothing
|
|
//! persisted yet to prefer), but for a finished job it's only whatever
|
|
//! was buffered by poll time — for a multi-minute build phase that can
|
|
//! be a small fraction of the real output. This is why the priority
|
|
//! matters: a failed `nix flake check` run once returned only the ~90s
|
|
//! eval-phase prefix and silently dropped the actual build-phase error.
|
|
//!
|
|
//! So: persisted first (complete once it exists) unless `--step` was
|
|
//! explicitly requested (only the streamer honors per-step framing); fall
|
|
//! back to the streamer when nothing's persisted yet.
|
|
|
|
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 (shown
|
|
/// by `pr-status`).
|
|
#[arg(long)]
|
|
run: u64,
|
|
/// Job index within the run (0-based, default 0).
|
|
#[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>,
|
|
/// Run attempt number (re-runs increment it; default 1).
|
|
#[arg(long, default_value_t = 1)]
|
|
attempt: u64,
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// Why the live streamer didn't produce logs, so `run` can tell a real usage
|
|
/// error (which must surface) from a pruned/empty run (which falls back to the
|
|
/// durable persisted-log download). Avoids brittle string-matching on the
|
|
/// error message to make that distinction.
|
|
enum StreamerMiss {
|
|
/// A requested `--step` index is past the job's step count — a usage
|
|
/// error; `run` surfaces it instead of masking it with the fallback.
|
|
StepOutOfRange(String),
|
|
/// The streamer is reachable but has no log lines, OR a transport / HTTP
|
|
/// failure (e.g. the `500 "resource does not exist"` a pruned run
|
|
/// returns). Either way `run` falls back to the durable download.
|
|
Unavailable,
|
|
}
|
|
|
|
/// Try the live run-view streamer. `Ok(())` means it printed (or emitted JSON
|
|
/// for) actual log lines. `Err(StreamerMiss::StepOutOfRange)` is a usage error
|
|
/// to surface; `Err(StreamerMiss::Unavailable)` tells the caller to fall back
|
|
/// to the durable persisted-log download.
|
|
fn streamer_logs(client: &Client, path: &str, args: &Args) -> Result<(), StreamerMiss> {
|
|
// 1. Discover the job's steps (state is returned regardless of cursors).
|
|
// A transport error / pruned-task 500 surfaces here → Unavailable.
|
|
let view = client
|
|
.post_json_web(path, &json!({ "logCursors": [] }))
|
|
.map_err(|_| StreamerMiss::Unavailable)?;
|
|
let steps = steps_of(&view);
|
|
if steps.is_empty() {
|
|
return Err(StreamerMiss::Unavailable);
|
|
}
|
|
|
|
// 2. Request the chosen step(s) from cursor 0, fully expanded.
|
|
let want: Vec<usize> = match args.step {
|
|
Some(s) if s >= steps.len() => {
|
|
return Err(StreamerMiss::StepOutOfRange(format!(
|
|
"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 }))
|
|
.map_err(|_| StreamerMiss::Unavailable)?;
|
|
|
|
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() {
|
|
return Err(StreamerMiss::Unavailable);
|
|
}
|
|
|
|
if client.json_mode() {
|
|
crate::verbs::print_json(&resp).map_err(|_| StreamerMiss::Unavailable)?;
|
|
return Ok(());
|
|
}
|
|
|
|
// Human output: a header per step, then its lines in order.
|
|
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(())
|
|
}
|
|
|
|
/// Durable fallback: download the persisted whole-job log via the web route
|
|
/// the run page's "view raw logs" link uses,
|
|
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>/attempt/<n>/logs`
|
|
/// (keyed by the per-repo run NUMBER, with the attempt segment). Prints the
|
|
/// text (or wraps it in JSON under `--json`). `--step` is not honored here —
|
|
/// the persisted log is flat.
|
|
///
|
|
/// Returns `Ok(true)` when it printed a real (non-empty) log, `Ok(false)`
|
|
/// when the persisted log doesn't exist yet (job still running — the
|
|
/// caller should fall back to the live streamer), and `Err` for a genuine
|
|
/// transport failure.
|
|
fn persisted_logs(client: &Client, repo: &str, args: &Args) -> Result<bool> {
|
|
let url = client.web_url(&format!(
|
|
"/{repo}/actions/runs/{}/jobs/{}/attempt/{}/logs",
|
|
args.run, args.job, args.attempt
|
|
));
|
|
let Ok(bytes) = client.get_bytes_raw(&url) else {
|
|
return Ok(false);
|
|
};
|
|
if bytes.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
let text = String::from_utf8_lossy(&bytes);
|
|
if client.json_mode() {
|
|
crate::verbs::print_json(&json!({
|
|
"run": args.run,
|
|
"job": args.job,
|
|
"source": "persisted",
|
|
"log": text,
|
|
}))?;
|
|
return Ok(true);
|
|
}
|
|
print!("{text}");
|
|
if !text.ends_with('\n') {
|
|
println!();
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the run/job can't be fetched from either source
|
|
/// (network, an unknown run number, or a non-2xx), if a requested `--step`
|
|
/// is out of range, or if both sources yield no log content.
|
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
|
let repo = client.repo();
|
|
let stream_path = format!("/{repo}/actions/runs/{}/jobs/{}", args.run, args.job);
|
|
|
|
// `--step` only has meaning against the live streamer's per-step
|
|
// framing (the persisted download is a flat whole-job log) — honor it
|
|
// directly instead of preferring the persisted route.
|
|
if args.step.is_some() {
|
|
return match streamer_logs(client, &stream_path, &args) {
|
|
Ok(()) => Ok(()),
|
|
Err(StreamerMiss::StepOutOfRange(msg)) => bail!(msg),
|
|
Err(StreamerMiss::Unavailable) => {
|
|
bail!(
|
|
"run #{} job {} has no live step log to read `--step` from \
|
|
(the run may have finished and the task record was pruned, \
|
|
or it hasn't started yet) — retry without `--step` to read \
|
|
the persisted whole-job log instead.",
|
|
args.run,
|
|
args.job
|
|
)
|
|
}
|
|
};
|
|
}
|
|
|
|
// 1. Durable persisted-log download first — complete once it exists,
|
|
// which is the common case for any run that has already finished
|
|
// (successfully or not). Only absent while the job is still running.
|
|
if persisted_logs(client, repo, &args)? {
|
|
return Ok(());
|
|
}
|
|
|
|
// 2. Live run-view streamer — the run hasn't finished yet (no persisted
|
|
// log), so read whatever's been buffered so far.
|
|
match streamer_logs(client, &stream_path, &args) {
|
|
Ok(()) => Ok(()),
|
|
Err(StreamerMiss::StepOutOfRange(msg)) => bail!(msg),
|
|
Err(StreamerMiss::Unavailable) => bail!(
|
|
"run #{} job {} returned no log from either the persisted download \
|
|
or the live streamer — the run may not exist or its logs were \
|
|
fully purged.",
|
|
args.run,
|
|
args.job
|
|
),
|
|
}
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|