//! `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. //! //! 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). //! //! 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. //! //! 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, json}; use crate::client::Client; #[derive(ClapArgs)] pub struct Args { /// 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, /// 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 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 (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 = 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" ) })?; 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, repo: &str, pr: u64) -> Result { let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?; pull.get("head") .and_then(|h| h.get("ref")) .and_then(Value::as_str) .map(str::to_string) .with_context(|| format!("ci-rerun: PR #{pr} has no head.ref")) } /// 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_dispatch_target, run_number_of}; use serde_json::json; #[test] 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_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_dispatch_target(&run, "fallback.yml"), Some(("ci.yml".to_string(), "atlas/foo".to_string())) ); } #[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); } }