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