hyperhive/hive-forge/src/verbs/ci_rerun.rs
atlas 55f01942a2 docs, prompts, hive-forge: stop handing readers the renamed verbs
docs/tools/forge.md already listed the nine renamed verbs as removed, then
used them ~30 more times in pasteable blocks. Sweeps every occurrence a
reader would type, including three runtime messages that told the user to
run a verb the same binary rejects.

The renamed-verb list itself keeps the old names; it is what documents them.

Refs #4155
2026-09-10 17:22:57 +02:00

185 lines
7.3 KiB
Rust

//! `ci-rerun --pr <n>` / `ci-rerun --run <n>` / `ci-rerun --branch <name>`
//! — 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/<owner>/<repo>/actions/workflows/<workflow>/dispatches` with
//! `{"ref": "<branch>"}`. 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).
//!
//! The branch (and, for `--run`, the workflow file) is resolved from the
//! given handle:
//! - `--pr <n>` → the PR's head branch; dispatches `--workflow` (default
//! `ci.yml`) on it.
//! - `--branch <name>` → dispatches `--workflow` on that branch directly.
//! - `--run <n>` → 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`).
//!
//! Dispatch re-runs the whole workflow (no single-job variant). ⚠️ `--pr`
//! verifies the code but doesn't reliably move the PR's own status
//! checks — see `docs/scheduler/ci.md`'s "CI checks" for why; re-check `pr status`.
use anyhow::{Context as _, Result, bail};
use clap::Args as ClapArgs;
use forgejo_api::structs::{ActionRun, DispatchWorkflowOption};
use super::ci_common::find_run_by_number;
use crate::client::{Client, index};
#[derive(ClapArgs)]
pub struct Args {
/// Re-run CI for this PR (its head branch). Mutually exclusive with
/// `--run` / `--branch`.
#[arg(long, conflicts_with_all = ["run", "branch"])]
pr: Option<u64>,
/// Re-run the same workflow on the same branch this run used. The run
/// number is the `runs/<n>` in the run-page URL. Mutually exclusive
/// with `--pr` / `--branch`.
#[arg(long, conflicts_with_all = ["pr", "branch"])]
run: Option<u64>,
/// Re-run `--workflow` on this branch. Mutually exclusive with
/// `--pr` / `--run`.
#[arg(long, conflicts_with_all = ["pr", "run"])]
branch: Option<String>,
/// Workflow file to run (default `ci.yml`). Ignored for `--run`,
/// which uses the run's own workflow.
#[arg(long, default_value = "ci.yml")]
workflow: String,
}
/// # Errors
///
/// Returns an error if none of `--pr` / `--run` / `--branch` is given, if a
/// `--pr` / `--run` handle can't be resolved (unknown PR/run, or a run
/// missing its ref), or if the dispatch POST fails (network, or a non-2xx
/// such as `404` for an unknown workflow file or branch).
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo()?;
let (workflow, branch) = match (args.pr, args.run, args.branch.as_deref()) {
(Some(pr), _, _) => (args.workflow.clone(), branch_for_pr(client, pr)?),
(_, Some(run), _) => resolve_run(client, repo, run, &args.workflow)?,
(_, _, Some(branch)) => (args.workflow.clone(), branch.to_string()),
(None, None, None) => {
bail!("ci-rerun: pass one of --pr <n>, --run <n>, or --branch <name>")
}
};
let (owner, name) = client.owner_repo()?;
let body = DispatchWorkflowOption {
inputs: None,
r#ref: branch.clone(),
return_run_info: None,
};
client
.api()
.dispatch_workflow(owner, name, &workflow, body)
.send()
.with_context(|| {
format!(
"dispatch workflow {workflow} on {branch} ({repo}) — the workflow \
file or the branch may not exist"
)
})?;
println!("dispatched a fresh run of {workflow} on {branch} ({repo})");
Ok(())
}
/// Resolve a PR's head branch name (`head.ref`) — the branch a same-repo PR
/// pushes to, which is the ref we dispatch the workflow on.
fn branch_for_pr(client: &Client, pr: u64) -> Result<String> {
let (owner, name) = client.owner_repo()?;
let pull = client
.api()
.repo_get_pull_request(owner, name, index(pr)?)
.send()?;
pull.head
.and_then(|h| h.r#ref)
.with_context(|| format!("ci-rerun: PR #{pr} has no head.ref"))
}
/// 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.
fn resolve_run(
client: &Client,
repo: &str,
run_number: u64,
fallback_workflow: &str,
) -> 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"))
}
/// 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 `#<n>` 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.
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
.workflow_id
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(fallback_workflow);
Some((workflow.to_string(), branch.to_string()))
}
#[cfg(test)]
mod tests {
use super::{ActionRun, run_dispatch_target};
use serde_json::json;
/// Build a typed run record from an API-shaped JSON fixture. The
/// struct's fields are all optional, but the timestamp / URL
/// fields deserialize through `with`-modules that require the
/// keys to be *present* (as `null`) — fill those in so partial
/// fixtures stay terse.
fn run_from(mut v: serde_json::Value) -> ActionRun {
let obj = v.as_object_mut().unwrap();
for key in ["created", "started", "stopped", "updated", "html_url"] {
obj.entry(key).or_insert(serde_json::Value::Null);
}
serde_json::from_value(v).unwrap()
}
#[test]
fn extracts_workflow_and_branch() {
let run = run_from(json!({
"prettyref": "atlas/foo",
"workflow_id": "ci.yml",
}));
assert_eq!(
run_dispatch_target(&run, "fallback.yml"),
Some(("ci.yml".to_string(), "atlas/foo".to_string()))
);
}
#[test]
fn falls_back_to_default_workflow_without_file() {
let run = run_from(json!({ "prettyref": "b" }));
assert_eq!(
run_dispatch_target(&run, "fallback.yml"),
Some(("fallback.yml".to_string(), "b".to_string()))
);
}
#[test]
fn no_ref_means_no_target() {
assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None);
}
}