hive-forge: add ci-runs listing verb, fix ci-log's ambiguous no-log message
This commit is contained in:
parent
55eaebc9cf
commit
56c0602c2f
6 changed files with 204 additions and 59 deletions
|
|
@ -176,6 +176,10 @@ enum Verb {
|
|||
/// Re-run CI without an empty commit. Pass one of `--pr <n>`,
|
||||
/// `--run <n>`, or `--branch <name>`; `--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")
|
||||
}
|
||||
|
|
|
|||
73
hive-forge/src/verbs/ci_common.rs
Normal file
73
hive-forge/src/verbs/ci_common.rs
Normal file
|
|
@ -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/<n>`
|
||||
/// tail) — the number the UI shows, `pr-status` surfaces, and every
|
||||
/// `--run <n>` 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<u64> {
|
||||
run.html_url
|
||||
.as_ref()
|
||||
.and_then(|u| u.as_str().rsplit('/').next())
|
||||
.and_then(|s| s.parse::<u64>().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<Option<ActionRun>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
//! - `--pr <n>` → the PR's head branch; dispatches `--workflow` (default
|
||||
//! `ci.yml`) on it.
|
||||
//! - `--branch <name>` → dispatches `--workflow` on that branch directly.
|
||||
//! - `--run <n>` → looks the run up in the Actions runs list (by the
|
||||
//! `runs/<n>` 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 <n>` → 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<String> {
|
|||
.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/<run-number>`, 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/<n>` tail),
|
||||
/// matching the `runs/<n>` the UI shows and `pr-status` surfaces.
|
||||
fn run_number_of(run: &ActionRun) -> Option<u64> {
|
||||
run.html_url
|
||||
.as_ref()
|
||||
.and_then(|u| u.as_str().rsplit('/').next())
|
||||
.and_then(|s| s.parse::<u64>().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!({
|
||||
|
|
|
|||
78
hive-forge/src/verbs/ci_runs.rs
Normal file
78
hive-forge/src/verbs/ci_runs.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! `ci-runs [--workflow <file>] [--branch <name>] [--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<String>,
|
||||
/// Only runs on this branch or ref (e.g. `main`, `damocles/foo`).
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
/// 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}");
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue