A workflow_dispatch run writes no commit status, so ci-rerun --pr could never clear the red (pull_request) check it claimed to be re-running for - it dispatched a fresh run and printed a success message regardless, even though the check stays red no matter how that run turns out. --pr now refuses up front, before dispatching, naming the mechanism and the working alternative (re-run from the web UI). --run and --branch are unchanged: --run's own PR-pseudo-ref resolution and --branch's direct dispatch are both untouched. Fixes the exit-code/honesty defect from #4613; the workflow_dispatch vs. pull_request event-type question (whether to close+reopen the PR to fire a real pull_request event) is a separate, parked decision.
260 lines
11 KiB
Rust
260 lines
11 KiB
Rust
//! `ci-rerun --pr <n>` / `ci-rerun --run <n>` / `ci-rerun --branch <name>`
|
|
//! — 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/<owner>/<repo>/actions/workflows/<workflow>/dispatches` with
|
|
//! `{"ref": "<branch>"}` — 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 <n>` → 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 <name>` → dispatches `--workflow` on that branch directly.
|
|
//! - `--run <n>` → 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 `#<n>`
|
|
//! pseudo-ref, not a real branch — that case resolves one hop further via
|
|
//! `branch_for_pr`.
|
|
//!
|
|
//! 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, index};
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
/// Re-run CI for this PR (its head branch). Mutually exclusive with
|
|
/// `--run` / `--branch`.
|
|
#[arg(long, conflicts_with_all = ["run", "branch"])]
|
|
pr: Option<u64>,
|
|
/// Re-run the same workflow on the same branch this run used. The run
|
|
/// number is the `runs/<n>` in the run-page URL. Mutually exclusive
|
|
/// with `--pr` / `--branch`.
|
|
#[arg(long, conflicts_with_all = ["pr", "branch"])]
|
|
run: Option<u64>,
|
|
/// Re-run `--workflow` on this branch. Mutually exclusive with
|
|
/// `--pr` / `--run`.
|
|
#[arg(long, conflicts_with_all = ["pr", "run"])]
|
|
branch: Option<String>,
|
|
/// 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 a
|
|
/// `--pr` / `--run` handle can't be resolved (unknown PR/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 <n>, --run <n>, or --branch <name>")
|
|
}
|
|
};
|
|
|
|
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` 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.
|
|
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 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, pr: u64) -> Result<String> {
|
|
let (owner, name) = client.owner_repo()?;
|
|
let pull = client
|
|
.api()
|
|
.repo_get_pull_request(owner, name, index(pr)?)
|
|
.send()?;
|
|
pull.head
|
|
.and_then(|h| h.r#ref)
|
|
.with_context(|| format!("ci-rerun: PR #{pr} has no head.ref"))
|
|
}
|
|
|
|
/// 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 (`#<n>`, not a real branch — workflow-dispatch
|
|
/// 500s on it), resolves the PR's actual head branch instead, same as
|
|
/// `--pr` would.
|
|
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"))?;
|
|
let branch = match pr_number_from_run_ref(&branch) {
|
|
Some(pr) => branch_for_pr(client, pr)
|
|
.with_context(|| format!("ci-rerun: run #{run_number} was triggered by PR #{pr}"))?,
|
|
None => branch,
|
|
};
|
|
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 `#<n>` pseudo-ref instead, resolved
|
|
/// 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 `#<n>` — 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<u64> {
|
|
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
|
|
// real branch; the pure extractor must NOT do that resolution
|
|
// 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"));
|
|
}
|
|
}
|