//! `ci-rerun --pr ` / `ci-rerun --run ` / `ci-rerun --branch ` //! — re-run CI without pushing an empty commit. //! //! 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": ""}` — same effect as the empty-commit trick, minus the //! commit (verified end-to-end on Forgejo 15.0.3, dispatches even a //! `pull_request`-only workflow). //! //! The branch (and, for `--run`, the workflow file) is resolved from the //! given handle: //! - `--pr ` → refuses instead of dispatching: a `workflow_dispatch` run //! writes no commit status, so it cannot clear a red `(pull_request)` //! check on the PR's sha — see `docs/scheduler/ci.md`'s "CI checks" for //! why. Re-run from the web UI instead. //! - `--branch ` → dispatches `--workflow` on that branch directly. //! - `--run ` → looks the run up by its display number (same convention //! as `ci-log` / `artifact-get` / `ci-runs`) and dispatches the same //! workflow + ref the run used. A PR-triggered run's ref is a `#` //! pseudo-ref, not a real branch — that case refuses for the same reason //! `--pr` does (same underlying run, same red check, same sha). //! //! Dispatch re-runs the whole workflow (no single-job variant). 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; #[derive(ClapArgs)] pub struct Args { /// Doesn't work: a `workflow_dispatch` run writes no commit status, so /// it can't clear a red `(pull_request)` check — re-run from the web UI /// instead. Mutually exclusive with `--run` / `--branch`. #[arg(long, conflicts_with_all = ["run", "branch"])] pr: Option, /// Re-run the same workflow on the same branch this run used. The run /// number is the `runs/` in the run-page URL. Refuses instead of /// dispatching if the run was itself PR-triggered, same as `--pr` and /// for the same reason. Mutually exclusive with `--pr` / `--branch`. #[arg(long, conflicts_with_all = ["pr", "branch"])] run: Option, /// Re-run `--workflow` on this branch. Mutually exclusive with /// `--pr` / `--run`. #[arg(long, conflicts_with_all = ["pr", "run"])] branch: Option, /// 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 /// `--pr` is given or `--run` names a PR-triggered run (refuses before /// dispatching — see the module doc), if a `--run` handle can't otherwise /// be resolved (unknown 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<()> { if let Some(pr) = args.pr { bail!(pr_refusal_message(pr)); } let repo = client.repo()?; let (workflow, branch) = match (args.run, args.branch.as_deref()) { (Some(run), _) => resolve_run(client, repo, run, &args.workflow)?, (_, Some(branch)) => (args.workflow.clone(), branch.to_string()), (None, None) => { bail!("ci-rerun: pass one of --pr , --run , or --branch ") } }; 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(()) } /// Why `--pr` (and `--run` against a PR-triggered run) refuses rather than /// dispatching: a `workflow_dispatch` run writes no commit status (see the /// module doc), so it cannot clear a red `(pull_request)` check on this /// PR's sha no matter how the workflow run itself turns out. Names the /// mechanism and the working alternative rather than just "not supported", /// since the caller needs to know *why* before deciding what to do /// instead. Shared by both refusal sites so the same underlying run gets /// the same story regardless of which handle named it. fn pr_refusal_message(pr: u64) -> String { format!( "ci-rerun --pr {pr}: a workflow_dispatch run writes no commit status, so this cannot \ clear a red (pull_request) check on PR #{pr} — re-run it from the web UI instead" ) } /// 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. When the run's own /// ref is a PR pseudo-ref (`#`, not a real branch), the run was /// triggered by that PR — refuses with [`pr_refusal_message`], same as /// `--pr` would, instead of dispatching a run whose status can't clear the /// PR's red check. 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}"))?; let (workflow, branch) = run_dispatch_target(&run, fallback_workflow) .with_context(|| format!("ci-rerun: run #{run_number} has no ref"))?; if let Some(pr) = pr_number_from_run_ref(&branch) { bail!(pr_refusal_message(pr)); } Ok((workflow, branch)) } /// 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 `#` pseudo-ref instead, turned /// into a refusal one call site up in [`resolve_run`]), 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())) } /// A run's `prettyref` for a PR-triggered run is `#` — the PR number, /// not a branch (same pseudo-ref form `ci-runs --branch` accepts as a /// listing filter, via its own separate `qualify_ref`; deliberately not /// shared, per that function's own doc comment: same field name, different /// domain). Returns the parsed PR number for exactly that form, `None` for /// anything else (a real branch name, or a malformed `#`-prefixed string). fn pr_number_from_run_ref(value: &str) -> Option { value.strip_prefix('#')?.parse().ok() } #[cfg(test)] mod tests { use super::{ActionRun, pr_number_from_run_ref, pr_refusal_message, 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); } #[test] fn pr_triggered_run_keeps_its_pseudo_ref_at_this_layer() { // resolve_run (untested here — does I/O) is what turns this into a // refusal; the pure extractor must NOT do that recognition itself, // or a run with a genuine branch named e.g. "#weird" (not possible // in git, but worth pinning the boundary) would be handled in two // different places. let run = run_from(json!({ "prettyref": "#4199", "workflow_id": "ci.yml" })); // lint:allow: PR-shaped test fixture, not a tracker reference assert_eq!( run_dispatch_target(&run, "fallback.yml"), Some(("ci.yml".to_string(), "#4199".to_string())) // lint:allow: PR-shaped test fixture, not a tracker reference ); } #[test] fn pr_ref_parses_the_number() { assert_eq!(pr_number_from_run_ref("#4199"), Some(4199)); // lint:allow: PR-shaped test fixture, not a tracker reference assert_eq!(pr_number_from_run_ref("#0"), Some(0)); } #[test] fn non_pr_refs_do_not_parse() { assert_eq!(pr_number_from_run_ref("main"), None); assert_eq!(pr_number_from_run_ref("atlas/4199-foo"), None); assert_eq!(pr_number_from_run_ref("#"), None); assert_eq!(pr_number_from_run_ref("#12a"), None); assert_eq!(pr_number_from_run_ref(""), None); } /// `run()` bails with this message before it ever builds a /// `DispatchWorkflowOption` or touches the client — this pins the /// message text without needing a live `Client` to exercise `run()` /// itself. The message must name the mechanism (`workflow_dispatch` /// writes no commit status), the PR, and the working alternative, /// not just say the path is unsupported. #[test] fn pr_refusal_names_the_mechanism_and_the_alternative() { let msg = pr_refusal_message(4199); // lint:allow: PR-shaped test fixture, not a tracker reference assert!(msg.contains("workflow_dispatch")); assert!(msg.contains("commit status")); assert!(msg.contains("PR #4199")); // lint:allow: PR-shaped test fixture, not a tracker reference assert!(msg.contains("web UI")); } /// `resolve_run` (untested here — does I/O) bails with exactly this /// message once it recognizes a run's dispatch target as a PR pseudo- /// ref: this pins the same discrimination (`pr_number_from_run_ref`) /// and the same message constructor (`pr_refusal_message`) that `--pr` /// uses, so `--run` against a PR-triggered run tells the same story as /// `--pr` against that run's own PR — not a second, differently-worded /// refusal for the identical underlying defect. #[test] fn run_triggered_by_pr_refuses_with_the_shared_message() { let run = run_from(json!({ "prettyref": "#4199", "workflow_id": "ci.yml" })); // lint:allow: PR-shaped test fixture, not a tracker reference let (_, branch) = run_dispatch_target(&run, "fallback.yml").unwrap(); let pr = pr_number_from_run_ref(&branch) .expect("a PR-triggered run's dispatch target is a PR pseudo-ref"); assert_eq!(pr_refusal_message(pr), pr_refusal_message(4199)); // lint:allow: PR-shaped test fixture, not a tracker reference } }