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 <n>` re-runs all jobs of a run (run number = the `runs/<n>` the UI shows, same value ci-log / artifact-get take, surfaced as a CI context's target_url by pr-status). - `--pr <n>` resolves the run from the PR head sha's CI status target_url. - `--job <i>` re-runs a single job. Forgejo exposes no REST endpoint for rerunning a run, so this rides the run page's web routes (`<base>/<owner>/<repo>/actions/runs/<n>[/jobs/<i>]/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.
This commit is contained in:
parent
658812c263
commit
cd4bdf4eea
5 changed files with 167 additions and 0 deletions
|
|
@ -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 (`<base>/<owner>/<repo>/actions/runs/<run>/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
|
||||
|
|
|
|||
|
|
@ -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 <n>
|
||||
/// [--job i]`, or `--pr <n>` 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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
hive-forge/src/verbs/ci_rerun.rs
Normal file
133
hive-forge/src/verbs/ci_rerun.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//! `ci-rerun --run <n> [--job <i>]` / `ci-rerun --pr <n>` — 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
|
||||
//! `<base>/<owner>/<repo>/actions/runs/<n>/rerun`, and re-running one job a
|
||||
//! POST to `<base>/<owner>/<repo>/actions/runs/<n>/jobs/<i>/rerun`.
|
||||
//!
|
||||
//! Both key off the per-repo run NUMBER (the `runs/<n>` 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/<n>` 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<u64>,
|
||||
/// 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<u64>,
|
||||
/// Re-run only this job index (0-based). Omit to re-run every job in
|
||||
/// the run.
|
||||
#[arg(long)]
|
||||
job: Option<u64>,
|
||||
}
|
||||
|
||||
/// # 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 <n> or --pr <n>"),
|
||||
};
|
||||
|
||||
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/<n>/jobs/<i>`); we parse `<n>` out of it.
|
||||
fn run_number_for_pr(client: &Client, repo: &str, pr: u64) -> Result<u64> {
|
||||
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 `<n>` in
|
||||
/// `…/actions/runs/<n>[/…]`. Returns `None` if the URL isn't a run URL.
|
||||
fn run_number_from_url(url: &str) -> Option<u64> {
|
||||
url.split_once("/actions/runs/")
|
||||
.map(|(_, rest)| rest)
|
||||
.map(|rest| rest.split(['/', '?', '#']).next().unwrap_or(rest))
|
||||
.and_then(|n| n.parse::<u64>().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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue