From 56c0602c2f4c2af99019a8d9e934ea7c800c4f8b Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 2 Sep 2026 02:10:50 +0200 Subject: [PATCH] hive-forge: add ci-runs listing verb, fix ci-log's ambiguous no-log message --- hive-forge/src/main.rs | 5 ++ hive-forge/src/verbs/ci_common.rs | 73 +++++++++++++++++++++++++++++ hive-forge/src/verbs/ci_log.rs | 37 +++++++++++++-- hive-forge/src/verbs/ci_rerun.rs | 68 ++++++--------------------- hive-forge/src/verbs/ci_runs.rs | 78 +++++++++++++++++++++++++++++++ hive-forge/src/verbs/mod.rs | 2 + 6 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 hive-forge/src/verbs/ci_common.rs create mode 100644 hive-forge/src/verbs/ci_runs.rs diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 24d7aba0..537d441f 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -176,6 +176,10 @@ enum Verb { /// Re-run CI without an empty commit. Pass one of `--pr `, /// `--run `, or `--branch `; `--workflow` defaults to `ci.yml`. CiRerun(verbs::ci_rerun::Args), + /// List CI Actions runs, newest first (`--workflow`, `--branch`, + /// `--limit`, `--page`) — the run numbers `ci-log`/`ci-rerun --run`/ + /// `artifact-get --run` take. + CiRuns(verbs::ci_runs::Args), /// Git credential-helper protocol (`get|store|erase`) — not a verb /// you run by hand. `clone` configures each checkout's /// `credential.helper` to invoke this, so git asks it for a token @@ -268,6 +272,7 @@ fn dispatch(client: &client::Client, verb: Verb) -> Result<()> { Verb::ArtifactGet(a) => verbs::artifact_get::run(client, a), Verb::CiLog(a) => verbs::ci_log::run(client, a), Verb::CiRerun(a) => verbs::ci_rerun::run(client, a), + Verb::CiRuns(a) => verbs::ci_runs::run(client, a), Verb::CredentialHelper(_) => { unreachable!("handled in `run` before client construction") } diff --git a/hive-forge/src/verbs/ci_common.rs b/hive-forge/src/verbs/ci_common.rs new file mode 100644 index 00000000..fd9c2964 --- /dev/null +++ b/hive-forge/src/verbs/ci_common.rs @@ -0,0 +1,73 @@ +//! Shared helpers for the CI Actions run verbs (`ci-log`, `ci-rerun`, +//! `ci-runs`). + +use anyhow::Result; +use forgejo_api::structs::{ActionRun, ListActionRunsQuery}; + +use crate::client::Client; + +/// The per-repo run NUMBER from a run object's `html_url` (`…/runs/` +/// tail) — the number the UI shows, `pr-status` surfaces, and every +/// `--run ` flag takes. +/// +/// ⚠️ NOT [`ActionRun::id`]: Forgejo's `GET +/// /repos/{owner}/{repo}/actions/runs/{run_id}` endpoint takes that +/// internal, cross-repo id, not this number — confirmed empirically +/// (a run with `id: 3284` had display number `3092`). Don't be tempted to +/// pass a `--run` value there. +pub(crate) fn run_number_of(run: &ActionRun) -> Option { + run.html_url + .as_ref() + .and_then(|u| u.as_str().rsplit('/').next()) + .and_then(|s| s.parse::().ok()) +} + +/// Look up a single run by its display number via `ListActionRunsQuery`'s +/// `run_number` filter — one request, not a page scan. Confirmed +/// empirically that the filter matches on the same number +/// [`run_number_of`] reads back out (both trace to `index_in_repo` +/// server-side). +/// +/// `Ok(None)` means the number doesn't exist in `repo` (a fresh dispatch +/// still queued, a typo, or a purged/deleted run); `Err` is a genuine +/// transport failure. +pub(crate) fn find_run_by_number(client: &Client, run_number: u64) -> Result> { + let (owner, name) = client.owner_repo()?; + let body = client + .api() + .list_action_runs( + owner, + name, + ListActionRunsQuery { + run_number: Some(i64::try_from(run_number).unwrap_or(i64::MAX)), + ..Default::default() + }, + ) + .send()?; + Ok(body.workflow_runs.unwrap_or_default().into_iter().next()) +} + +#[cfg(test)] +mod tests { + use super::run_number_of; + use serde_json::json; + + fn run_from(mut v: serde_json::Value) -> forgejo_api::structs::ActionRun { + let obj = v.as_object_mut().unwrap(); + for key in ["created", "started", "stopped", "updated", "html_url"] { + obj.entry(key).or_insert(serde_json::Value::Null); + } + serde_json::from_value(v).unwrap() + } + + #[test] + fn parses_run_number_from_html_url() { + let run = run_from(json!({ "html_url": "http://forge/h/h/actions/runs/750" })); + assert_eq!(run_number_of(&run), Some(750)); + assert_eq!( + run_number_of(&run_from(json!({ "html_url": "http://forge/o/r/x" }))), + None + ); + assert_eq!(run_number_of(&run_from(json!({}))), None); + } +} diff --git a/hive-forge/src/verbs/ci_log.rs b/hive-forge/src/verbs/ci_log.rs index b90329cb..33312182 100644 --- a/hive-forge/src/verbs/ci_log.rs +++ b/hive-forge/src/verbs/ci_log.rs @@ -26,6 +26,7 @@ use anyhow::{Result, bail}; use clap::Args as ClapArgs; use serde_json::{Value, json}; +use super::ci_common::find_run_by_number; use crate::client::Client; #[derive(ClapArgs)] @@ -235,12 +236,38 @@ pub fn run(client: &Client, args: Args) -> Result<()> { 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.", + Err(StreamerMiss::Unavailable) => bail!(no_log_message(client, &args)), + } +} + +/// Both log sources came back empty — tell the caller whether that's +/// because the run doesn't exist at all, or because it does but genuinely +/// has no log yet (queued, mid-run with nothing buffered, or purged). +/// A single cheap lookup ([`find_run_by_number`]) resolves the ambiguity +/// that used to leave both cases reading identically. +fn no_log_message(client: &Client, args: &Args) -> String { + match find_run_by_number(client, args.run) { + Ok(Some(found)) => format!( + "run #{} exists (status: {}) but produced no log from either the \ + persisted download or the live streamer — it may still be \ + queued, or its logs were purged after finishing.", args.run, - args.job + found.status.as_deref().unwrap_or("unknown"), + ), + Ok(None) => format!( + "no such run #{} in {} — check the number (`ci-runs` lists real \ + ones) or that the dispatch that was meant to create it actually \ + landed.", + args.run, + client.repo(), + ), + // The existence lookup itself failed (network, auth) — don't let a + // secondary failure mask the original "no log" finding. + Err(_) => format!( + "run #{} job {} returned no log from either the persisted download \ + or the live streamer, and a follow-up lookup to tell \"no such \ + run\" from \"no log yet\" also failed.", + args.run, args.job ), } } diff --git a/hive-forge/src/verbs/ci_rerun.rs b/hive-forge/src/verbs/ci_rerun.rs index 2682e984..82cec1a8 100644 --- a/hive-forge/src/verbs/ci_rerun.rs +++ b/hive-forge/src/verbs/ci_rerun.rs @@ -20,10 +20,10 @@ //! - `--pr ` → the PR's head branch; dispatches `--workflow` (default //! `ci.yml`) on it. //! - `--branch ` → dispatches `--workflow` on that branch directly. -//! - `--run ` → looks the run up in the Actions runs list (by the -//! `runs/` tail of its `html_url`, same convention as `ci-log` / -//! `artifact-get`) and dispatches the SAME workflow on the SAME ref the -//! run used (the run record's `prettyref` + `workflow_id`). +//! - `--run ` → looks the run up by its display number (same convention +//! as `ci-log` / `artifact-get` / `ci-runs`) and dispatches the SAME +//! workflow on the SAME ref the run used (the run record's `prettyref` + +//! `workflow_id`). //! //! Dispatch re-runs the whole workflow (no single-job variant). ⚠️ `--pr` //! verifies the code but doesn't reliably move the PR's own status @@ -31,8 +31,9 @@ use anyhow::{Context as _, Result, bail}; use clap::Args as ClapArgs; -use forgejo_api::structs::{ActionRun, DispatchWorkflowOption, ListActionRunsQuery}; +use forgejo_api::structs::{ActionRun, DispatchWorkflowOption}; +use super::ci_common::find_run_by_number; use crate::client::{Client, index}; #[derive(ClapArgs)] @@ -107,47 +108,19 @@ fn branch_for_pr(client: &Client, pr: u64) -> Result { .with_context(|| format!("ci-rerun: PR #{pr} has no head.ref")) } -/// Page the Actions runs list (newest-first) to find the run whose run-page -/// `html_url` ends in `/runs/`, returning the `(workflow, branch)` -/// to dispatch a fresh run of it. `fallback_workflow` is used when the run -/// carries no workflow file name. +/// Resolve the run whose display number is `run_number` to the +/// `(workflow, branch)` to dispatch a fresh run of it. `fallback_workflow` +/// is used when the run carries no workflow file name. fn resolve_run( client: &Client, repo: &str, run_number: u64, fallback_workflow: &str, ) -> Result<(String, String)> { - const PER_PAGE: u32 = 50; - const MAX_PAGES: u32 = 40; - let (owner, name) = client.owner_repo()?; - for page in 1..=MAX_PAGES { - let body = client - .api() - .list_action_runs(owner, name, ListActionRunsQuery::default()) - .page(page) - .page_size(PER_PAGE) - .send()?; - let runs = body.workflow_runs.unwrap_or_default(); - if runs.is_empty() { - break; - } - for run in &runs { - if run_number_of(run) == Some(run_number) { - return run_dispatch_target(run, fallback_workflow) - .with_context(|| format!("ci-rerun: run #{run_number} has no ref")); - } - } - } - bail!("ci-rerun: run #{run_number} not found in {repo} (no matching workflow run)"); -} - -/// The per-repo run NUMBER from a run object's `html_url` (`…/runs/` tail), -/// matching the `runs/` the UI shows and `pr-status` surfaces. -fn run_number_of(run: &ActionRun) -> Option { - run.html_url - .as_ref() - .and_then(|u| u.as_str().rsplit('/').next()) - .and_then(|s| s.parse::().ok()) + let run = find_run_by_number(client, run_number)? + .with_context(|| format!("ci-rerun: run #{run_number} not found in {repo}"))?; + run_dispatch_target(&run, fallback_workflow) + .with_context(|| format!("ci-rerun: run #{run_number} has no ref")) } /// Pull the `(workflow-file, ref)` dispatch target out of a run record: @@ -168,7 +141,7 @@ fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(Stri #[cfg(test)] mod tests { - use super::{ActionRun, run_dispatch_target, run_number_of}; + use super::{ActionRun, run_dispatch_target}; use serde_json::json; /// Build a typed run record from an API-shaped JSON fixture. The @@ -184,19 +157,6 @@ mod tests { serde_json::from_value(v).unwrap() } - #[test] - fn parses_run_number_from_html_url() { - let run = run_from(json!({ "html_url": "http://forge/h/h/actions/runs/750" })); - assert_eq!(run_number_of(&run), Some(750)); - let run = run_from(json!({ "html_url": "https://forge/o/r/actions/runs/42" })); - assert_eq!(run_number_of(&run), Some(42)); - assert_eq!( - run_number_of(&run_from(json!({ "html_url": "http://forge/o/r/x" }))), - None - ); - assert_eq!(run_number_of(&run_from(json!({}))), None); - } - #[test] fn extracts_workflow_and_branch() { let run = run_from(json!({ diff --git a/hive-forge/src/verbs/ci_runs.rs b/hive-forge/src/verbs/ci_runs.rs new file mode 100644 index 00000000..ac46e920 --- /dev/null +++ b/hive-forge/src/verbs/ci_runs.rs @@ -0,0 +1,78 @@ +//! `ci-runs [--workflow ] [--branch ] [--limit N] [--page N]` — +//! list a repo's CI Actions runs, newest first. +//! +//! A `ci-rerun` dispatch creates a `workflow_dispatch` run that publishes +//! no commit status, so there was previously no way from the CLI to see +//! what it actually created — or to discover a real run number to hand +//! `ci-log`/`artifact-get` at all, short of guessing near a known one. +//! This lists runs directly; `--workflow`/`--branch` filter server-side +//! via `ListActionRunsQuery`, not a client-side scan. + +use anyhow::Result; +use clap::Args as ClapArgs; +use forgejo_api::structs::{ActionRun, ListActionRunsQuery}; + +use super::ci_common::run_number_of; +use crate::client::Client; +use crate::verbs::print_json; + +#[derive(ClapArgs)] +pub struct Args { + /// Only runs of this workflow file (e.g. `ci.yml`). + #[arg(long)] + workflow: Option, + /// Only runs on this branch or ref (e.g. `main`, `damocles/foo`). + #[arg(long)] + branch: Option, + /// How many runs to print (default 20). + #[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..))] + limit: u64, + /// Page number (1-based, default 1). Combine with `--limit` to page + /// through further back than the default window. + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u64).range(1..))] + page: u64, +} + +/// # Errors +/// +/// Returns an error if the list request fails (network, or a non-2xx). +pub fn run(client: &Client, args: Args) -> Result<()> { + let (owner, name) = client.owner_repo()?; + let query = ListActionRunsQuery { + workflow_id: args.workflow.clone(), + r#ref: args.branch.clone(), + ..Default::default() + }; + let body = client + .api() + .list_action_runs(owner, name, query) + .page(u32::try_from(args.page).unwrap_or(u32::MAX)) + .page_size(u32::try_from(args.limit).unwrap_or(u32::MAX)) + .send()?; + let runs = body.workflow_runs.unwrap_or_default(); + + if client.json_mode() { + return print_json(&serde_json::to_value(&runs)?); + } + + if runs.is_empty() { + println!("no matching runs in {}", client.repo()); + return Ok(()); + } + for run in &runs { + print_row(run); + } + Ok(()) +} + +/// One human-readable line per run: number, status, workflow file, ref, +/// title. Widths are cosmetic alignment, not a fixed schema — a longer +/// value just pushes the next column over rather than truncating. +fn print_row(run: &ActionRun) { + let number = run_number_of(run).map_or_else(|| "?".to_owned(), |n| format!("#{n}")); + let status = run.status.as_deref().unwrap_or("unknown"); + let workflow = run.workflow_id.as_deref().unwrap_or("?"); + let run_ref = run.prettyref.as_deref().unwrap_or("?"); + let title = run.title.as_deref().unwrap_or(""); + println!("{number:<7} {status:<10} {workflow:<20} {run_ref:<28} {title}"); +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index c86756f5..7f66cc43 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -8,8 +8,10 @@ pub mod assign; pub mod attach; pub mod attachment_get; pub mod branches; +pub mod ci_common; pub mod ci_log; pub mod ci_rerun; +pub mod ci_runs; pub mod clone; pub mod close; pub mod comment;