From cd4bdf4eeaa127fd55bbba4e6e7687b605d6489d Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 22 Jun 2026 19:46:04 +0200 Subject: [PATCH 1/3] 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; From c9c59c2a1d3aff4d95911dc5042be74e0eb094e2 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 11:25:33 +0200 Subject: [PATCH 2/3] hive-forge: ci-rerun dispatches a fresh run via workflow-dispatch API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation POSTed Forgejo's run-page rerun web route, which is CSRF-gated and answers a bare token POST with 404 — so the verb never actually re-ran anything against the agent token. Rework it to dispatch a fresh run of the workflow via the GitHub-compatible workflow-dispatch API (POST /repos///actions/workflows//dispatches {"ref":}), which accepts a plain agent token (verified end-to-end on Forgejo 15.0.3). A dispatched run is equivalent to the old empty-commit retrigger, minus the commit. The branch is resolved from exactly one of --pr (the PR head branch), --run (branch + workflow looked up from that run in the Actions runs list), or --branch (directly); --workflow picks the workflow file for --pr/--branch (default ci.yml). Dispatch re-runs the whole workflow, so the old --job single-job variant is dropped. Also add workflow_dispatch to .forgejo/workflows/ci.yml for explicitness (Forgejo 15.0.3 dispatches the pull_request workflow without it, but the trigger makes the API path intent-clear and cross-version robust), remove the now-unused Client::post_web_no_content, and update docs/tools/forge.md. --- .forgejo/workflows/ci.yml | 3 + docs/tools/forge.md | 21 ++- hive-forge/src/client.rs | 20 --- hive-forge/src/verbs/ci_rerun.rs | 249 ++++++++++++++++++++----------- 4 files changed, 175 insertions(+), 118 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 20f71eca..985a80fc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,6 +3,9 @@ name: CI on: pull_request: branches: ["**"] + # Lets `hive-forge ci-rerun` re-trigger CI via the workflow-dispatch API + # without an empty commit. No effect on the PR-triggered runs above. + workflow_dispatch: jobs: check: diff --git a/docs/tools/forge.md b/docs/tools/forge.md index a92969e3..4240360d 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -76,7 +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 ci-rerun --pr 42 # re-run CI without an empty commit (dispatches a fresh run; --run n / --branch name also work) hive-forge subscription --watch # subscribe to repo notifications hive-forge subscription --unwatch # unsubscribe hive-forge -r internal/knowledge clone # clone with creds auto-injected @@ -173,12 +173,17 @@ 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). +- `ci-rerun` re-runs CI without pushing an empty commit (the old + retrigger path, which littered PR history). Forgejo has no token-usable + REST endpoint to re-run an *existing* run (the run-page rerun buttons + are CSRF-gated web routes a token POST 404s), so this dispatches a + **fresh** run of the workflow via the workflow-dispatch API + (`POST …/actions/workflows//dispatches {"ref":""}`). + Resolve the branch with exactly one of: `--pr ` (the PR's head + branch), `--run ` (the same run number `ci-log` / `artifact-get` + take — resolves the branch + workflow from that run), or `--branch + ` (directly). `--workflow ` picks the workflow file for + `--pr` / `--branch` (default `ci.yml`). Dispatch re-runs the whole + workflow — there is no single-job variant. - 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 10e132bf..c6ffa3d6 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -321,26 +321,6 @@ 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/verbs/ci_rerun.rs b/hive-forge/src/verbs/ci_rerun.rs index e50de033..e7d47d96 100644 --- a/hive-forge/src/verbs/ci_rerun.rs +++ b/hive-forge/src/verbs/ci_rerun.rs @@ -1,133 +1,202 @@ -//! `ci-rerun --run [--job ]` / `ci-rerun --pr ` — re-run a CI -//! Actions workflow run without pushing an empty commit. +//! `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. This verb POSTs the -//! rerun action directly. +//! to be an empty commit, which litters PR history. //! -//! 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`. +//! 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). //! -//! 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. +//! The branch (and, for `--run`, the workflow file) is resolved from the +//! given handle: +//! - `--pr ` → the PR's head branch; dispatches `--workflow` (default +//! `ci.yml`) on it. +//! - `--branch ` → dispatches `--workflow` on that branch directly. +//! - `--run ` → looks the run up in the Actions runs list (by the +//! `runs/` tail of its `html_url`, same convention as `ci-log` / +//! `artifact-get`) and dispatches the SAME workflow on the SAME branch the +//! run used. //! -//! `--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. +//! Dispatch re-runs the whole workflow, so there is no single-job variant. use anyhow::{Context as _, Result, bail}; use clap::Args as ClapArgs; -use serde_json::Value; +use serde_json::{Value, json}; 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)] + /// Re-run CI for this PR: resolves the PR's head branch and dispatches + /// `--workflow` on it. Mutually exclusive with `--run` / `--branch`. + #[arg(long, conflicts_with_all = ["run", "branch"])] pr: Option, - /// Re-run only this job index (0-based). Omit to re-run every job in - /// the run. - #[arg(long)] - job: Option, + /// Dispatch a fresh run of the workflow that produced this run, on the + /// same branch the run used. The run number is the `runs/` in the + /// run-page URL — what `pr-status` surfaces as a CI context's + /// `target_url`. Mutually exclusive with `--pr` / `--branch`. + #[arg(long, conflicts_with_all = ["pr", "branch"])] + run: Option, + /// Dispatch `--workflow` on this branch directly. Mutually exclusive + /// with `--pr` / `--run`. + #[arg(long, conflicts_with_all = ["pr", "run"])] + branch: Option, + /// Workflow file to dispatch for `--pr` / `--branch` (the file name under + /// `.forgejo/workflows/`). Ignored for `--run`, which resolves the + /// workflow from the run itself (falling back to this value). + #[arg(long, default_value = "ci.yml")] + workflow: String, } /// # 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). +/// 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 branch), 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 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 (workflow, branch) = match (args.pr, args.run, args.branch.as_deref()) { + (Some(pr), _, _) => (args.workflow.clone(), branch_for_pr(client, repo, 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 , --run , or --branch ") + } }; - 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}")) - ) - })?; + let path = format!("/repos/{repo}/actions/workflows/{workflow}/dispatches"); + client + .post_no_content(&path, &json!({ "ref": branch })) + .with_context(|| { + format!( + "dispatch workflow {workflow} on {branch} ({repo}) — the workflow \ + file or the branch may not exist" + ) + })?; - 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}"), - } + println!("dispatched a fresh run of {workflow} on {branch} ({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 { +/// 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, 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")) + pull.get("head") + .and_then(|h| h.get("ref")) .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})") - }) + .map(str::to_string) + .with_context(|| format!("ci-rerun: PR #{pr} has no head.ref")) } -/// 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()) +/// Page the Actions runs list (newest-first) to find the run whose run-page +/// `html_url` ends in `/runs/`, returning the `(workflow, branch)` +/// to dispatch a fresh run of it. `fallback_workflow` is used when the run +/// carries no workflow `path`. +fn resolve_run( + client: &Client, + repo: &str, + run_number: u64, + fallback_workflow: &str, +) -> Result<(String, String)> { + const PER_PAGE: u32 = 50; + const MAX_PAGES: u32 = 40; + for page in 1..=MAX_PAGES { + let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}"); + let body = client.get_json(&path)?; + let runs = body + .get("workflow_runs") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if runs.is_empty() { + break; + } + for run in &runs { + if run_number_of(run) == Some(run_number) { + return run_dispatch_target(run, fallback_workflow) + .with_context(|| format!("ci-rerun: run #{run_number} has no head_branch")); + } + } + } + bail!("ci-rerun: run #{run_number} not found in {repo} (no matching workflow run)"); +} + +/// The per-repo run NUMBER from a run object's `html_url` (`…/runs/` tail), +/// matching the `runs/` the UI shows and `pr-status` surfaces. +fn run_number_of(run: &Value) -> Option { + run.get("html_url") + .and_then(Value::as_str) + .and_then(|u| u.rsplit('/').next()) + .and_then(|s| s.parse::().ok()) +} + +/// Pull the `(workflow-file, branch)` dispatch target out of a run object: +/// `head_branch` is the branch, and the workflow file is the basename of the +/// run's `path` (e.g. `.forgejo/workflows/ci.yml` → `ci.yml`), falling back to +/// `fallback_workflow` when the run carries no usable `path`. `None` only when +/// the run has no `head_branch`. +fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String, String)> { + let branch = run.get("head_branch").and_then(Value::as_str)?; + let workflow = run + .get("path") + .and_then(Value::as_str) + .and_then(|p| p.rsplit('/').next()) + .filter(|s| !s.is_empty()) + .unwrap_or(fallback_workflow); + Some((workflow.to_string(), branch.to_string())) } #[cfg(test)] mod tests { - use super::run_number_from_url; + use super::{run_dispatch_target, run_number_of}; + use serde_json::json; #[test] - fn parses_run_number_from_actions_url() { + fn parses_run_number_from_html_url() { + let run = json!({ "html_url": "http://forge/h/h/actions/runs/750" }); + assert_eq!(run_number_of(&run), Some(750)); + let run = json!({ "html_url": "https://forge/o/r/actions/runs/42" }); + assert_eq!(run_number_of(&run), Some(42)); assert_eq!( - run_number_from_url("http://forge/h/h/actions/runs/750/jobs/0"), - Some(750) + run_number_of(&json!({ "html_url": "http://forge/o/r/x" })), + None ); + assert_eq!(run_number_of(&json!({})), None); + } + + #[test] + fn extracts_workflow_and_branch() { + let run = json!({ + "head_branch": "atlas/foo", + "path": ".forgejo/workflows/ci.yml", + }); assert_eq!( - run_number_from_url("https://forge/o/r/actions/runs/42"), - Some(42) + run_dispatch_target(&run, "fallback.yml"), + Some(("ci.yml".to_string(), "atlas/foo".to_string())) ); - assert_eq!(run_number_from_url("http://forge/o/r/commit/abc"), None); - assert_eq!(run_number_from_url("/actions/runs/notanumber/x"), None); + } + + #[test] + fn falls_back_to_default_workflow_without_path() { + let run = json!({ "head_branch": "b" }); + assert_eq!( + run_dispatch_target(&run, "fallback.yml"), + Some(("fallback.yml".to_string(), "b".to_string())) + ); + } + + #[test] + fn no_branch_means_no_target() { + assert_eq!(run_dispatch_target(&json!({}), "ci.yml"), None); } } From abd70531d535ba06d52ad8208fc3582e0dd8860c Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 12:01:05 +0200 Subject: [PATCH 3/3] hive-forge: fix stale ci-rerun CLI variant doc The clap subcommand doc in main.rs still described the old web-route implementation (--job, 'POSTs the rerun web action'), which surfaces in hive-forge --help. Update it to match the workflow-dispatch rework. --- hive-forge/src/main.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index cd81f066..6735f718 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -165,9 +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. + /// Re-run CI without an empty commit: dispatches a fresh run via the + /// workflow-dispatch API. Pass one of `--pr ` (the PR head branch), + /// `--run ` (branch + workflow resolved from that run), or + /// `--branch `; `--workflow ` defaults to `ci.yml`. CiRerun(verbs::ci_rerun::Args), }