hyperhive/hive-forge/src/verbs/ci_log.rs
atlas e816cf4d72 fix(ci-log): address review — prose tags, correct module doc, typed miss
- Remove the two issue-number tags from Rust comments (tracker-tag lint).
- Correct the module doc: the durable download is keyed by the per-repo
  run NUMBER + attempt segment (matches the code + persisted_logs doc),
  not a global id — the earlier 'global id' wording was stale.
- Replace the fragile e.to_string().contains("out of range") branch with
  a typed StreamerMiss enum (StepOutOfRange vs Unavailable), so a usage
  error can never silently fall through to the persisted-log path if a
  message string changes.
2026-06-23 22:45:05 +02:00

266 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, tried in order, because each covers a different
//! lifecycle window:
//!
//! 1. **Live run-view streamer** — `POST <base>/<owner>/<repo>/actions/runs/
//! <run>/jobs/<job>` with a `{"logCursors":[{"step":N,"cursor":C,
//! "expanded":bool}]}` body, the same endpoint the run page polls. It
//! gives rich per-step framing but reads the **live `act_runner` task
//! record**, which Forgejo prunes once a run completes — so a run that
//! finished a while ago returns `500 "task ... resource does not exist"`
//! or no lines. Keyed by the per-repo run NUMBER (the `runs/<n>` the UI
//! shows and `pr-status` surfaces as a CI context `target_url`).
//! 2. **Durable persisted-log download** — when the streamer is pruned the
//! step-log files still exist on disk (the web UI reads them fine after
//! the streamer 500s). We fetch them via the web download 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 (same value `--run` takes) with the
//! attempt segment. This is a flat whole-job log with no per-step framing,
//! so `--step` is not honored on this path.
//!
//! Result: live + recent runs get the rich streamed view; older / quickly-
//! pruned runs (where `act_runner` has already pruned the live task record)
//! still print via the durable fallback instead of a bare
//! "logs garbage-collected" error.
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.
/// Honored on the live-streamer path only; the durable persisted-log
/// fallback serves a flat whole-job log and ignores `--step`.
#[arg(long)]
step: Option<usize>,
/// Run attempt number for the durable persisted-log download (re-runs
/// increment it; default 1 covers the common single-attempt case).
#[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.
fn persisted_logs(client: &Client, repo: &str, args: &Args) -> Result<()> {
let url = client.web_url(&format!(
"/{repo}/actions/runs/{}/jobs/{}/attempt/{}/logs",
args.run, args.job, args.attempt
));
let bytes = client.get_bytes_raw(&url)?;
if bytes.is_empty() {
bail!(
"run #{} job {} returned an empty log from both the live streamer \
and the persisted-log download — the run may not exist or its logs \
were fully purged.",
args.run,
args.job
);
}
let text = String::from_utf8_lossy(&bytes);
if client.json_mode() {
return crate::verbs::print_json(&json!({
"run": args.run,
"job": args.job,
"source": "persisted",
"log": text,
}));
}
print!("{text}");
if !text.ends_with('\n') {
println!();
}
Ok(())
}
/// # 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 the live streamer and the persisted-log
/// download 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);
// 1. Live run-view streamer first — rich per-step framing for in-progress
// and recently-finished runs.
match streamer_logs(client, &stream_path, &args) {
Ok(()) => return Ok(()),
// An out-of-range `--step` is a real usage error, not a pruned run —
// surface it instead of masking it with the fallback.
Err(StreamerMiss::StepOutOfRange(msg)) => bail!(msg),
// Pruned task or reachable-but-no-lines → fall through to the durable
// download (survives the act_runner task prune that 500s the streamer).
Err(StreamerMiss::Unavailable) => {}
}
// 2. Durable persisted-log download (the pruned-run case this verb fixes).
persisted_logs(client, repo, &args)
}
#[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"
);
}
}