hyperhive/hive-forge/src/verbs/ci_runs.rs
iris 17554ea563 fix: sync generated hive-forge CLI docs with their clap source strings
Same bug as the swarmctl/hivectl fix, a third instance argus's review
didn't name but nix/checks.nix's hive-forge-docs freshness check (same
pattern as hivectl-docs/swarmctl-docs) caught in CI: the earlier
Contractions/Foreign/Auto batches edited docs/tools/forge-cli.md
directly instead of the clap #[arg(...)]/doc-comment strings in
hive-forge/src/main.rs and hive-forge/src/verbs/*.rs.

Applied the same 13 wording changes to source that the earlier commits
already made to the generated .md, matched 1:1 against
'git diff origin/main HEAD -- docs/tools/forge-cli.md' rather than
guessed. Several source doc comments feed two rendered sections each
(e.g. reaction.rs's one Add-variant doc renders under both
'issue reaction add' and 'pr reaction add', since both subcommands
share the same enum) -- one source fix, two generated-doc fixes.

Regenerated from the now-fixed source and confirmed byte-identical to
what's already committed (diff exit 0) -- source and generated output
are back in sync, same as the swarmctl/hivectl fix.

cargo clippy -p hive-forge --all-targets -- -D warnings and
scripts/check-doc-refs.sh both clean.
2026-09-07 16:28:06 +02:00

137 lines
5.1 KiB
Rust

//! `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 (for example `ci.yml`).
#[arg(long)]
workflow: Option<String>,
/// Only runs on this ref. A branch name (`main`, `damocles/foo`) or a
/// PR (`#N`) is qualified for you; a `refs/…` value is used as
/// given. An all-digit value is read as a PR number — to filter a
/// branch literally named that, pass `refs/heads/<name>`.
#[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.as_deref().map(qualify_ref),
..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(())
}
/// Qualify a `--branch` value into the full ref the runs-list filter
/// matches on. It only matches a complete ref, while the listing prints
/// `prettyref` (`main`, `#N`) — so without this every value a caller
/// can read off the output comes back as an empty result set, which is
/// indistinguishable from a branch that has never been built.
///
/// A slash cannot be used to detect an already-qualified ref: branch
/// names contain slashes (`damocles/3932-merge-collisions-lint`), so
/// `refs/` is the only reliable marker and anything else is a branch.
///
/// Deliberately not shared with `ci-rerun`, whose `ref` is a
/// `workflow_dispatch` body field taking a **bare** branch name. Same
/// field name, different domain; qualifying there would break it.
fn qualify_ref(value: &str) -> String {
if value.starts_with("refs/") {
return value.to_owned();
}
let digits = value.strip_prefix('#').unwrap_or(value);
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
return format!("refs/pull/{digits}/head");
}
format!("refs/heads/{value}")
}
/// 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}");
}
#[cfg(test)]
mod tests {
use super::qualify_ref;
#[test]
fn qualifies_a_bare_branch_name() {
assert_eq!(qualify_ref("main"), "refs/heads/main");
}
#[test]
fn a_slash_does_not_imply_an_already_qualified_ref() {
assert_eq!(qualify_ref("damocles/foo"), "refs/heads/damocles/foo");
}
#[test]
fn reads_a_pr_number_as_its_pull_head_ref() {
assert_eq!(qualify_ref("#3967"), "refs/pull/3967/head"); // lint:allow test input
assert_eq!(qualify_ref("3967"), "refs/pull/3967/head");
}
#[test]
fn leaves_a_qualified_ref_alone() {
assert_eq!(qualify_ref("refs/heads/main"), "refs/heads/main");
assert_eq!(qualify_ref("refs/pull/3967/merge"), "refs/pull/3967/merge");
}
#[test]
fn a_lone_hash_is_a_branch_name_not_an_empty_pr() {
assert_eq!(qualify_ref("#"), "refs/heads/#");
}
}