//! `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 (for example `ci.yml`). #[arg(long)] workflow: Option, /// 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/`. #[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.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/#"); } }