diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 18444951..c86382b6 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -55,7 +55,6 @@ 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 # 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 @@ -136,15 +135,5 @@ 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-.zip` by default; pass `-o -` to stream to stdout. -- `ci-log --run [--job i] [--step i]` prints a CI run's job step - logs. `` 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. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 16a81223..9534d6fa 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -283,28 +283,6 @@ 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 - /// `///actions/runs//jobs/`. 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(&self, path: &str, body: &B) -> Result { - 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 diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index fbd4fc74..3a1e3665 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -130,10 +130,6 @@ 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 [--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<()> { @@ -171,6 +167,5 @@ 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), } } diff --git a/hive-forge/src/verbs/ci_log.rs b/hive-forge/src/verbs/ci_log.rs deleted file mode 100644 index 5b5a779b..00000000 --- a/hive-forge/src/verbs/ci_log.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! `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). - 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 = 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" - ); - } -} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 843fef70..90b8fc4c 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -8,7 +8,6 @@ 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;