hive-forge: resolve ci-rerun --run's PR pseudo-ref before dispatching

A run triggered by a pull_request-event workflow carries a #<n> pseudo-ref
in prettyref rather than a real branch name. Dispatching a workflow with
that as the ref 500s. resolve_run now recognizes the #<n> form and falls
back to the same branch_for_pr lookup --pr already uses, so ci-rerun --run
works on PR-triggered runs the same way ci-rerun --pr does.

diagnosis + discriminating control table (real branch vs #<n> pseudo-ref)
from atlas.

fixes #4201
This commit is contained in:
damocles 2026-09-11 12:29:48 +02:00 committed by mara
commit 7b9d8e4c4c

View file

@ -1,19 +1,14 @@
//! `ci-rerun --pr <n>` / `ci-rerun --run <n>` / `ci-rerun --branch <name>` //! `ci-rerun --pr <n>` / `ci-rerun --run <n>` / `ci-rerun --branch <name>`
//! — re-run CI without pushing an empty commit. //! — 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*: //! 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 //! 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 //! POST answers with `404`. Instead this verb dispatches a **fresh** run of
//! the workflow via the GitHub-compatible workflow-dispatch API, //! the workflow via the GitHub-compatible workflow-dispatch API,
//! `POST /repos/<owner>/<repo>/actions/workflows/<workflow>/dispatches` with //! `POST /repos/<owner>/<repo>/actions/workflows/<workflow>/dispatches` with
//! `{"ref": "<branch>"}`. That creates a brand-new run on the branch — the //! `{"ref": "<branch>"}` — same effect as the empty-commit trick, minus the
//! same effect as the empty-commit trick, minus the commit — and accepts a //! commit (verified end-to-end on Forgejo 15.0.3, dispatches even a
//! plain agent token (verified end-to-end on Forgejo 15.0.3, which dispatches //! `pull_request`-only workflow).
//! even a `pull_request`-only workflow).
//! //!
//! The branch (and, for `--run`, the workflow file) is resolved from the //! The branch (and, for `--run`, the workflow file) is resolved from the
//! given handle: //! given handle:
@ -21,9 +16,10 @@
//! `ci.yml`) on it. //! `ci.yml`) on it.
//! - `--branch <name>` → dispatches `--workflow` on that branch directly. //! - `--branch <name>` → dispatches `--workflow` on that branch directly.
//! - `--run <n>` → looks the run up by its display number (same convention //! - `--run <n>` → looks the run up by its display number (same convention
//! as `ci-log` / `artifact-get` / `ci-runs`) and dispatches the SAME //! 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 + ref the run used. A PR-triggered run's ref is a `#<n>`
//! `workflow_id`). //! 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` //! Dispatch re-runs the whole workflow (no single-job variant). ⚠️ `--pr`
//! verifies the code but doesn't reliably move the PR's own status //! 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<String> {
/// Resolve the run whose display number is `run_number` to the /// Resolve the run whose display number is `run_number` to the
/// `(workflow, branch)` to dispatch a fresh run of it. `fallback_workflow` /// `(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 (`#<n>`, not a real branch — workflow-dispatch
/// 500s on it), resolves the PR's actual head branch instead, same as
/// `--pr` would.
fn resolve_run( fn resolve_run(
client: &Client, client: &Client,
repo: &str, repo: &str,
@ -119,16 +118,22 @@ fn resolve_run(
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let run = find_run_by_number(client, run_number)? let run = find_run_by_number(client, run_number)?
.with_context(|| format!("ci-rerun: run #{run_number} not found in {repo}"))?; .with_context(|| format!("ci-rerun: run #{run_number} not found in {repo}"))?;
run_dispatch_target(&run, fallback_workflow) let (workflow, branch) = run_dispatch_target(&run, fallback_workflow)
.with_context(|| format!("ci-rerun: run #{run_number} has no ref")) .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: /// 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 / /// `prettyref` is the ref the run ran on (the branch name for push /
/// dispatch runs — PR-event runs carry a `#<n>` pseudo-ref the dispatch /// dispatch runs — PR-event runs carry a `#<n>` pseudo-ref instead, resolved
/// endpoint will reject with a clear 404), and `workflow_id` is the /// one call site up in [`resolve_run`]), and `workflow_id` is the workflow
/// workflow file name (e.g. `ci.yml`), falling back to /// file name (e.g. `ci.yml`), falling back to `fallback_workflow` when
/// `fallback_workflow` when absent. `None` only when the run has no ref. /// absent. `None` only when the run has no ref.
fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(String, String)> { fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(String, String)> {
let branch = run.prettyref.as_deref().filter(|s| !s.is_empty())?; let branch = run.prettyref.as_deref().filter(|s| !s.is_empty())?;
let workflow = run 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())) Some((workflow.to_string(), branch.to_string()))
} }
/// A run's `prettyref` for a PR-triggered run is `#<n>` — 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<u64> {
value.strip_prefix('#')?.parse().ok()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ActionRun, run_dispatch_target}; use super::{ActionRun, pr_number_from_run_ref, run_dispatch_target};
use serde_json::json; use serde_json::json;
/// Build a typed run record from an API-shaped JSON fixture. The /// Build a typed run record from an API-shaped JSON fixture. The
@ -182,4 +197,33 @@ mod tests {
fn no_ref_means_no_target() { fn no_ref_means_no_target() {
assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None); 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);
}
} }