//! `ci-log --run [--job ] [--step ]` — 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, //! `///actions/runs//jobs//attempt//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/` 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, /// 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 { 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 = 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 = 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, /// `///actions/runs//jobs//attempt//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 { 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" ); } }