diff --git a/hive-forge/src/verbs/ci_rerun.rs b/hive-forge/src/verbs/ci_rerun.rs index 9fa511b1..8eebc9a7 100644 --- a/hive-forge/src/verbs/ci_rerun.rs +++ b/hive-forge/src/verbs/ci_rerun.rs @@ -1,19 +1,14 @@ //! `ci-rerun --pr ` / `ci-rerun --run ` / `ci-rerun --branch ` //! — re-run CI without pushing an empty commit. //! -//! When a run fails for a transient reason (a remote-builder flap, a -//! cold-daemon window, an `act_runner` hiccup) the only retrigger path used -//! to be an empty commit, which litters PR history. -//! //! Forgejo exposes no token-usable REST endpoint to *re-run an existing run*: //! the run-page rerun buttons hit CSRF-gated web routes that a bare token //! POST answers with `404`. Instead this verb dispatches a **fresh** run of //! the workflow via the GitHub-compatible workflow-dispatch API, //! `POST /repos///actions/workflows//dispatches` with -//! `{"ref": ""}`. That creates a brand-new run on the branch — the -//! same effect as the empty-commit trick, minus the commit — and accepts a -//! plain agent token (verified end-to-end on Forgejo 15.0.3, which dispatches -//! even a `pull_request`-only workflow). +//! `{"ref": ""}` — same effect as the empty-commit trick, minus the +//! commit (verified end-to-end on Forgejo 15.0.3, dispatches even a +//! `pull_request`-only workflow). //! //! The branch (and, for `--run`, the workflow file) is resolved from the //! given handle: @@ -21,9 +16,10 @@ //! `ci.yml`) on it. //! - `--branch ` → dispatches `--workflow` on that branch directly. //! - `--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`). +//! as `ci-log` / `artifact-get` / `ci-runs`) and dispatches the same +//! workflow + ref the run used. A PR-triggered run's ref is a `#` +//! pseudo-ref, not a real branch — that case resolves one hop further via +//! the same `branch_for_pr` lookup `--pr` uses. //! //! Dispatch re-runs the whole workflow (no single-job variant). ⚠️ `--pr` //! verifies the code but doesn't reliably move the PR's own status @@ -110,7 +106,10 @@ fn branch_for_pr(client: &Client, pr: u64) -> Result { /// 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. +/// is used when the run carries no workflow file name. When the run's own +/// ref is a PR pseudo-ref (`#`, not a real branch — workflow-dispatch +/// 500s on it), resolves the PR's actual head branch instead, same as +/// `--pr` would. fn resolve_run( client: &Client, repo: &str, @@ -119,16 +118,22 @@ fn resolve_run( ) -> Result<(String, String)> { 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")) + let (workflow, branch) = run_dispatch_target(&run, fallback_workflow) + .with_context(|| format!("ci-rerun: run #{run_number} has no ref"))?; + let branch = match pr_number_from_run_ref(&branch) { + Some(pr) => branch_for_pr(client, pr) + .with_context(|| format!("ci-rerun: run #{run_number} was triggered by PR #{pr}"))?, + None => branch, + }; + Ok((workflow, branch)) } /// Pull the `(workflow-file, ref)` dispatch target out of a run record: /// `prettyref` is the ref the run ran on (the branch name for push / -/// dispatch runs — PR-event runs carry a `#` pseudo-ref the dispatch -/// endpoint will reject with a clear 404), and `workflow_id` is the -/// workflow file name (e.g. `ci.yml`), falling back to -/// `fallback_workflow` when absent. `None` only when the run has no ref. +/// dispatch runs — PR-event runs carry a `#` pseudo-ref instead, resolved +/// one call site up in [`resolve_run`]), and `workflow_id` is the workflow +/// file name (e.g. `ci.yml`), falling back to `fallback_workflow` when +/// absent. `None` only when the run has no ref. fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(String, String)> { let branch = run.prettyref.as_deref().filter(|s| !s.is_empty())?; let workflow = run @@ -139,9 +144,19 @@ fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(Stri Some((workflow.to_string(), branch.to_string())) } +/// A run's `prettyref` for a PR-triggered run is `#` — the PR number, +/// not a branch (same pseudo-ref form `ci-runs --branch` accepts as a +/// listing filter, via its own separate `qualify_ref`; deliberately not +/// shared, per that function's own doc comment: same field name, different +/// domain). Returns the parsed PR number for exactly that form, `None` for +/// anything else (a real branch name, or a malformed `#`-prefixed string). +fn pr_number_from_run_ref(value: &str) -> Option { + value.strip_prefix('#')?.parse().ok() +} + #[cfg(test)] mod tests { - use super::{ActionRun, run_dispatch_target}; + use super::{ActionRun, pr_number_from_run_ref, run_dispatch_target}; use serde_json::json; /// Build a typed run record from an API-shaped JSON fixture. The @@ -182,4 +197,33 @@ mod tests { fn no_ref_means_no_target() { assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None); } + + #[test] + fn pr_triggered_run_keeps_its_pseudo_ref_at_this_layer() { + // resolve_run (untested here — does I/O) is what turns this into a + // real branch; the pure extractor must NOT do that resolution + // itself, or a run with a genuine branch named e.g. "#weird" (not + // possible in git, but worth pinning the boundary) would be handled + // in two different places. + let run = run_from(json!({ "prettyref": "#4199", "workflow_id": "ci.yml" })); // lint:allow: PR-shaped test fixture, not a tracker reference + assert_eq!( + run_dispatch_target(&run, "fallback.yml"), + Some(("ci.yml".to_string(), "#4199".to_string())) // lint:allow: PR-shaped test fixture, not a tracker reference + ); + } + + #[test] + fn pr_ref_parses_the_number() { + assert_eq!(pr_number_from_run_ref("#4199"), Some(4199)); // lint:allow: PR-shaped test fixture, not a tracker reference + assert_eq!(pr_number_from_run_ref("#0"), Some(0)); + } + + #[test] + fn non_pr_refs_do_not_parse() { + assert_eq!(pr_number_from_run_ref("main"), None); + assert_eq!(pr_number_from_run_ref("atlas/4199-foo"), None); + assert_eq!(pr_number_from_run_ref("#"), None); + assert_eq!(pr_number_from_run_ref("#12a"), None); + assert_eq!(pr_number_from_run_ref(""), None); + } }