From cd4bdf4eeaa127fd55bbba4e6e7687b605d6489d Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 22 Jun 2026 19:46:04 +0200 Subject: [PATCH] feat(#1778): add `hive-forge ci-rerun` to re-run CI without an empty commit When a CI run fails for a transient reason (remote-builder flap, cold-daemon window, act_runner hiccup) the only retrigger path was an empty commit, which litters PR history and forces a force-push to clean up. This verb POSTs the rerun action directly. - `ci-rerun --run ` re-runs all jobs of a run (run number = the `runs/` the UI shows, same value ci-log / artifact-get take, surfaced as a CI context's target_url by pr-status). - `--pr ` resolves the run from the PR head sha's CI status target_url. - `--job ` re-runs a single job. Forgejo exposes no REST endpoint for rerunning a run, so this rides the run page's web routes (`///actions/runs/[/jobs/]/rerun`) via a new `Client::post_web_no_content` (web base like post_json_web, tolerates the redirect/empty response the rerun handler returns). Mirrors ci-log's web-route approach + auth path. docs/tools/forge.md updated with the verb. --- docs/tools/forge.md | 8 ++ hive-forge/src/client.rs | 20 +++++ hive-forge/src/main.rs | 5 ++ hive-forge/src/verbs/ci_rerun.rs | 133 +++++++++++++++++++++++++++++++ hive-forge/src/verbs/mod.rs | 1 + 5 files changed, 167 insertions(+) create mode 100644 hive-forge/src/verbs/ci_rerun.rs diff --git a/docs/tools/forge.md b/docs/tools/forge.md index f68908b0..a92969e3 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -76,6 +76,7 @@ hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a c hive-forge attachment-get # download an attachment; prints resolved path to stdout hive-forge artifact-get pr1ma-paper-pdf --run 51 # download a CI run's Actions artifact zip (run number from the run-page URL) hive-forge ci-log --run 51 # print a CI run's job step logs (run number from the run-page URL); --job i / --step i to narrow +hive-forge ci-rerun --run 51 # re-run a CI run without an empty commit (--pr n resolves the head run; --job i re-runs one job) hive-forge subscription --watch # subscribe to repo notifications hive-forge subscription --unwatch # unsubscribe hive-forge -r internal/knowledge clone # clone with creds auto-injected @@ -172,5 +173,12 @@ plain comment show under `last comment`, not `reviews`. is reliable for live + recently-finished runs; when logs are gone the verb says so rather than printing nothing. `--json` dumps the raw run-view response. +- `ci-rerun --run [--job i]` re-runs a CI Actions run without + pushing an empty commit (the old retrigger path, which littered PR + history). `` is the same run number `ci-log` / `artifact-get` take; + `--pr ` resolves the run from a PR head sha's CI status instead. + `--job i` re-runs a single job (0-based); omit to re-run all jobs. + Forgejo exposes no REST endpoint, so it POSTs the run page's rerun web + route (which answers with a redirect, not JSON). - Do NOT use raw `curl` for forge access -- the CLI handles auth, error checking, and output formatting. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index c6ffa3d6..10e132bf 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -321,6 +321,26 @@ impl Client { decode_json(resp, &format!("POST {url}")) } + /// POST to a base-relative *web* path (NOT under `/api/v1/`) whose + /// response carries no useful body — e.g. the Actions run rerun + /// endpoints (`///actions/runs//rerun`), which + /// answer with a redirect to the run page rather than JSON. Same + /// token-auth path as `post_json_web` (the web router accepts a + /// token-authed doer and skips CSRF for non-session auth); the bodyless + /// POST mirrors the form-handler's expectations (run/job come from the + /// URL). reqwest follows the redirect, so a 2xx on the final hop is + /// success. `path` should start with `/`. + /// + /// # Errors + /// Returns an error on a transport failure or a non-2xx final status + /// (e.g. `404` for an unknown run). + pub fn post_web_no_content(&self, path: &str) -> Result<()> { + let url = self.web_url(path); + let resp = self.http.post(&url).send().context("POST")?; + check_status(resp, &format!("POST {url}"))?; + Ok(()) + } + /// GET a raw (non-API) URL and return the response body as bytes. /// The client's auth headers are still sent — Forgejo requires them /// for private attachment downloads. Uses the full URL as-is; the diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 0e82b6ed..cd81f066 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -165,6 +165,10 @@ enum Verb { /// [--step i]`). Uses Forgejo's web run-view streamer (no REST /// endpoint exists); reliable for live + recently-finished runs. CiLog(verbs::ci_log::Args), + /// Re-run a CI Actions run without an empty commit (`--run + /// [--job i]`, or `--pr ` to resolve the head run). POSTs the + /// rerun web action; re-runs all jobs unless `--job` is given. + CiRerun(verbs::ci_rerun::Args), } fn main() -> Result<()> { @@ -205,5 +209,6 @@ fn main() -> Result<()> { Verb::AttachmentGet(a) => verbs::attachment_get::run(&client, a), Verb::ArtifactGet(a) => verbs::artifact_get::run(&client, a), Verb::CiLog(a) => verbs::ci_log::run(&client, a), + Verb::CiRerun(a) => verbs::ci_rerun::run(&client, a), } } diff --git a/hive-forge/src/verbs/ci_rerun.rs b/hive-forge/src/verbs/ci_rerun.rs new file mode 100644 index 00000000..e50de033 --- /dev/null +++ b/hive-forge/src/verbs/ci_rerun.rs @@ -0,0 +1,133 @@ +//! `ci-rerun --run [--job ]` / `ci-rerun --pr ` — re-run a CI +//! Actions workflow run 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. This verb POSTs the +//! rerun action directly. +//! +//! Forgejo exposes no REST endpoint for rerunning a run; the rerun buttons +//! on the run page hit web routes. Re-running all jobs is a POST to +//! `///actions/runs//rerun`, and re-running one job a +//! POST to `///actions/runs//jobs//rerun`. +//! +//! Both key off the per-repo run NUMBER (the `runs/` the UI shows and +//! `pr-status` surfaces as a CI context `target_url`), so `--run` is used +//! directly with no id translation — same convention as `ci-log`. They +//! reply with a redirect to the run page rather than a body, so this drives +//! `Client::post_web_no_content`. The auth path matches `ci-log`'s web POST: +//! a token-authed doer, no `_csrf` needed. +//! +//! `--pr` is a convenience: it resolves the PR head sha's CI status and +//! pulls the run number out of the status `target_url`, then re-runs that +//! run's jobs. + +use anyhow::{Context as _, Result, bail}; +use clap::Args as ClapArgs; +use serde_json::Value; + +use crate::client::Client; + +#[derive(ClapArgs)] +pub struct Args { + /// Workflow run number — the `runs/` in the run-page URL, which + /// `pr-status` surfaces as a CI context's `target_url`. Mutually + /// exclusive with `--pr`. + #[arg(long, conflicts_with = "pr")] + run: Option, + /// Re-run the latest run for this PR's head commit. Resolves the run + /// number from the head sha's CI status. Mutually exclusive with + /// `--run`. + #[arg(long)] + pr: Option, + /// Re-run only this job index (0-based). Omit to re-run every job in + /// the run. + #[arg(long)] + job: Option, +} + +/// # Errors +/// +/// Returns an error if neither `--run` nor `--pr` is given, if `--pr` can't +/// be resolved to a run number (no CI status on the head commit yet), or if +/// the rerun POST fails (network, or a non-2xx such as `404` for an unknown +/// run). +pub fn run(client: &Client, args: Args) -> Result<()> { + let repo = client.repo(); + let run = match (args.run, args.pr) { + (Some(n), _) => n, + (None, Some(pr)) => run_number_for_pr(client, repo, pr)?, + (None, None) => bail!("ci-rerun: pass one of --run or --pr "), + }; + + let path = match args.job { + Some(job) => format!("/{repo}/actions/runs/{run}/jobs/{job}/rerun"), + None => format!("/{repo}/actions/runs/{run}/rerun"), + }; + client.post_web_no_content(&path).with_context(|| { + format!( + "rerun run #{run}{} — the run may not exist, or the rerun route may \ + differ on this Forgejo version", + args.job.map_or_else(String::new, |j| format!(" job {j}")) + ) + })?; + + match args.job { + Some(job) => println!("re-running run #{run} job {job} on {repo}"), + None => println!("re-running all jobs of run #{run} on {repo}"), + } + Ok(()) +} + +/// Resolve a PR's latest CI run number from its head sha's combined status. +/// The Actions status `target_url` points at the run page +/// (`…/actions/runs//jobs/`); we parse `` out of it. +fn run_number_for_pr(client: &Client, repo: &str, pr: u64) -> Result { + let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?; + let sha = pull + .get("head") + .and_then(|h| h.get("sha")) + .and_then(Value::as_str) + .with_context(|| format!("ci-rerun: PR #{pr} has no head.sha"))?; + let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?; + let statuses = combined + .get("statuses") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + statuses + .iter() + .filter_map(|s| s.get("target_url").and_then(Value::as_str)) + .find_map(run_number_from_url) + .with_context(|| { + format!("ci-rerun: no Actions run found in PR #{pr}'s CI status (head {sha})") + }) +} + +/// Pull the run number out of an Actions run-page URL, i.e. the `` in +/// `…/actions/runs/[/…]`. Returns `None` if the URL isn't a run URL. +fn run_number_from_url(url: &str) -> Option { + url.split_once("/actions/runs/") + .map(|(_, rest)| rest) + .map(|rest| rest.split(['/', '?', '#']).next().unwrap_or(rest)) + .and_then(|n| n.parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::run_number_from_url; + + #[test] + fn parses_run_number_from_actions_url() { + assert_eq!( + run_number_from_url("http://forge/h/h/actions/runs/750/jobs/0"), + Some(750) + ); + assert_eq!( + run_number_from_url("https://forge/o/r/actions/runs/42"), + Some(42) + ); + assert_eq!(run_number_from_url("http://forge/o/r/commit/abc"), None); + assert_eq!(run_number_from_url("/actions/runs/notanumber/x"), None); + } +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index e1b28c33..0e2bc60c 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -9,6 +9,7 @@ pub mod attach; pub mod attachment_get; pub mod branches; pub mod ci_log; +pub mod ci_rerun; pub mod clone; pub mod close; pub mod comment;