diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 985a80fc..20f71eca 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,9 +3,6 @@ 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 4240360d..f68908b0 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -76,7 +76,6 @@ 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 --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,17 +172,5 @@ 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` 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/main.rs b/hive-forge/src/main.rs index 6735f718..0e82b6ed 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -165,11 +165,6 @@ 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 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), } fn main() -> Result<()> { @@ -210,6 +205,5 @@ 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 deleted file mode 100644 index e7d47d96..00000000 --- a/hive-forge/src/verbs/ci_rerun.rs +++ /dev/null @@ -1,202 +0,0 @@ -//! `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); - } -} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 0e2bc60c..e1b28c33 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -9,7 +9,6 @@ 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;