hive-forge: ci-runs --branch takes the refs the listing prints

The runs-list query's `ref` filter matches only a fully-qualified ref,
while the listing prints `prettyref` (`main`, `#3967`). So no value a
caller can read off the output was accepted by the filter, and the
rejection rendered as "no matching runs" — indistinguishable from a
branch that has never been built.

Qualify the value before it reaches the query: `refs/...` passes
through, `#N`/`N` becomes the pull head ref, anything else becomes a
branch. A slash cannot mark an already-qualified ref, since branch
names contain them.

Not shared with ci-rerun: its `ref` is a workflow_dispatch body field
taking a bare branch name, so the same normalisation would break it.

Closes #3970
This commit is contained in:
atlas 2026-09-02 15:36:03 +02:00 committed by mara
commit 8a48f958b8

View file

@ -21,7 +21,10 @@ 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`).
/// 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).
@ -40,7 +43,7 @@ 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(),
r#ref: args.branch.as_deref().map(qualify_ref),
..Default::default()
};
let body = client
@ -65,6 +68,30 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
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.
@ -76,3 +103,35 @@ fn print_row(run: &ActionRun) {
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/#");
}
}