hive-forge: add ci-log verb for actions job step logs (closes #1674)
This commit is contained in:
parent
1d259e84f2
commit
2223af1b21
5 changed files with 216 additions and 0 deletions
|
|
@ -55,6 +55,7 @@ hive-forge attach-issue 42 /path/to/file # upload a file attachment to an iss
|
|||
hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL
|
||||
hive-forge attachment-get <uuid> # download an attachment; prints resolved path to stdout
|
||||
hive-forge artifact-get pr1ma-paper-pdf --run 51 # download a CI run's Actions artifact zip (run number from the run-page URL)
|
||||
hive-forge ci-log --run 51 # print a CI run's job step logs (run number from the run-page URL); --job i / --step i to narrow
|
||||
hive-forge subscription --watch # subscribe to repo notifications
|
||||
hive-forge subscription --unwatch # unsubscribe
|
||||
hive-forge -r internal/knowledge clone # clone with creds auto-injected
|
||||
|
|
@ -135,5 +136,15 @@ plain comment show under `last comment`, not `reviews`.
|
|||
the run's internal global id, so the verb translates the run number
|
||||
first. Saves a zip to `/tmp/forge-artifact-<name>.zip` by default; pass
|
||||
`-o -` to stream to stdout.
|
||||
- `ci-log --run <n> [--job i] [--step i]` prints a CI run's job step
|
||||
logs. `<n>` is the run number from the run-page URL (same value
|
||||
`artifact-get` takes; `pr-status` surfaces it as a CI context's
|
||||
target_url). Forgejo exposes no REST endpoint for job logs, so the
|
||||
verb drives the web run-view streamer the run page polls. `--job`
|
||||
selects the job within the run (0-based, default 0); `--step` narrows
|
||||
to one step. `act_runner` garbage-collects completed-run logs, so this
|
||||
is reliable for live + recently-finished runs; when logs are gone the
|
||||
verb says so rather than printing nothing. `--json` dumps the raw
|
||||
run-view response.
|
||||
- Do NOT use raw `curl` for forge access -- the CLI handles auth,
|
||||
error checking, and output formatting.
|
||||
|
|
|
|||
|
|
@ -283,6 +283,28 @@ impl Client {
|
|||
format!("{}{path}", self.base)
|
||||
}
|
||||
|
||||
/// POST a JSON body to a base-relative *web* path (NOT under
|
||||
/// `/api/v1/`) and decode the JSON response. Used for endpoints
|
||||
/// Forgejo only serves through its web router — e.g. the Actions
|
||||
/// run-view log streamer at
|
||||
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The
|
||||
/// agent's `Authorization: token` header is sent as usual; the
|
||||
/// web router accepts a token-authed doer and, because the handler
|
||||
/// consumes a JSON body rather than a CSRF-bound HTML form, no
|
||||
/// `_csrf` token is required (same auth path `get_bytes_raw` uses
|
||||
/// for the web artifact-download route). `path` should start `/`.
|
||||
pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
||||
let url = self.web_url(path);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.json(body)
|
||||
.send()
|
||||
.context("POST")?;
|
||||
decode_json(resp, &format!("POST {url}"))
|
||||
}
|
||||
|
||||
/// GET a raw (non-API) URL and return the response body as bytes.
|
||||
/// The client's auth headers are still sent — Forgejo requires them
|
||||
/// for private attachment downloads. Uses the full URL as-is; the
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ enum Verb {
|
|||
/// caller supplies the run number + artifact name. Saves a zip
|
||||
/// (or `-o -` to stream).
|
||||
ArtifactGet(verbs::artifact_get::Args),
|
||||
/// Print a CI Actions run's job step logs (`--run <n> [--job i]
|
||||
/// [--step i]`). Uses Forgejo's web run-view streamer (no REST
|
||||
/// endpoint exists); reliable for live + recently-finished runs.
|
||||
CiLog(verbs::ci_log::Args),
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
|
|
@ -167,5 +171,6 @@ fn main() -> Result<()> {
|
|||
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
|
||||
Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a),
|
||||
Verb::ArtifactGet(a) => verbs::artifact_get::run(&client, a),
|
||||
Verb::CiLog(a) => verbs::ci_log::run(&client, a),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
hive-forge/src/verbs/ci_log.rs
Normal file
177
hive-forge/src/verbs/ci_log.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! `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).
|
||||
let view = client.post_json_web(&path, &json!({ "logCursors": [] }))?;
|
||||
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).unwrap_or_default() as usize;
|
||||
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::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn steps_of_reads_current_job_steps() {
|
||||
let view = 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(&json!({})).is_empty());
|
||||
assert!(steps_of(&json!({ "state": {} })).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_name_falls_back() {
|
||||
assert_eq!(step_name(Some(&json!({ "name": "compile" }))), "compile");
|
||||
assert_eq!(step_name(Some(&json!({}))), "step");
|
||||
assert_eq!(step_name(None), "step");
|
||||
// `summary` wins over `name` when both are present.
|
||||
assert_eq!(
|
||||
step_name(Some(&json!({ "summary": "a", "name": "b" }))),
|
||||
"a"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ pub mod assign;
|
|||
pub mod attach;
|
||||
pub mod attachment_get;
|
||||
pub mod branches;
|
||||
pub mod ci_log;
|
||||
pub mod clone;
|
||||
pub mod close;
|
||||
pub mod comment;
|
||||
|
|
|
|||
Loading…
Reference in a new issue