Per mara's go-ahead on hyperhive#3902 ("getting started is good, but
terminal rendering does not go in there i think"):
Moved 21 top-level docs/*.md files into 7 new topic subdirectories
(existing web-ui/, turn-loop/, swarm/, tools/, crates/ untouched):
getting-started/ setup.md
agent-lifecycle/ agent-hierarchy.md, approvals.md, persistence.md
trust-boundary/ boundary.md, security.md
integrations/ forge.md, matrix.md, github.md, knowledge.md
networking/ gateway.md, network.md, snapshot-store.md
scheduler/ jobq.md, coordinator.md, ci.md, observability.md
process/ conventions.md, gotchas.md, pr-review-gate.md
web-ui/ terminal-rendering.md (moved into the EXISTING dir,
per mara's correction to the original getting-started
guess -- it's UI implementation detail, not onboarding)
The physical layout now matches docs/README.md's own topical headers,
which already amounted to this taxonomy -- see the scoping comment on
the issue for the two findings that motivated this (a genuine
duplication between CLAUDE.md's old "Reading paths" list and
docs/README.md's grouped one, since drifted out of sync with each
other; and the flat layout not matching the grouping we already had).
Fixed every cross-reference this moved across the whole repo (~120
files: docs/ internal links at every depth, Rust doc comments, nix
module option docs, crate READMEs) -- verified two ways: a grep sweep
confirming zero remaining references to any old path, and a script
that resolves every markdown link in docs/**/*.md + CLAUDE.md +
README.md against the filesystem and reports anything that doesn't
exist (zero broken links).
Collapsed CLAUDE.md's "Reading paths" section (the duplicate) down to
a pointer at docs/README.md, now the single index. Rewrote
docs/README.md itself to use the new subdirectory paths and added the
one doc it was missing that CLAUDE.md's old copy had (pr-review-gate.md).
Classified all 22 docs/*.md files first via a haiku subagent (mara's
suggestion) on two axes -- proposed grouping and operator-vs-
implementation focus -- before finalizing the taxonomy; spot-checked
the report and found internal inconsistencies (its classification
table disagreed with its own summary section for a few files), so this
taxonomy is my original proposal + the one correction mara gave
directly, not a blind application of the subagent's table. The
operator-focus data it gathered is still useful for a follow-up
content pass (docs skewing 'mixed' rather than pure operator-facing),
not addressed in this PR -- structure only.
nix fmt clean, both pre-push lints clean.
225 lines
8.8 KiB
Rust
225 lines
8.8 KiB
Rust
//! `ci-rerun --pr <n>` / `ci-rerun --run <n>` / `ci-rerun --branch <name>`
|
|
//! — 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/<owner>/<repo>/actions/workflows/<workflow>/dispatches` with
|
|
//! `{"ref": "<branch>"}`. 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 <n>` → the PR's head branch; dispatches `--workflow` (default
|
|
//! `ci.yml`) on it.
|
|
//! - `--branch <name>` → dispatches `--workflow` on that branch directly.
|
|
//! - `--run <n>` → looks the run up in the Actions runs list (by the
|
|
//! `runs/<n>` tail of its `html_url`, same convention as `ci-log` /
|
|
//! `artifact-get`) and dispatches the SAME workflow on the SAME ref the
|
|
//! run used (the run record's `prettyref` + `workflow_id`).
|
|
//!
|
|
//! Dispatch re-runs the whole workflow (no single-job variant). ⚠️ `--pr`
|
|
//! verifies the code but doesn't reliably move the PR's own status
|
|
//! checks — see `docs/scheduler/ci.md`'s "CI checks" for why; re-check `pr-status`.
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use clap::Args as ClapArgs;
|
|
use forgejo_api::structs::{ActionRun, DispatchWorkflowOption, ListActionRunsQuery};
|
|
|
|
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<()> {
|
|
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, 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 <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(())
|
|
}
|
|
|
|
/// 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"))
|
|
}
|
|
|
|
/// Page the Actions runs list (newest-first) to find the run whose run-page
|
|
/// `html_url` ends in `/runs/<run-number>`, returning the `(workflow, branch)`
|
|
/// to dispatch a fresh run of it. `fallback_workflow` is used when the run
|
|
/// carries no workflow file name.
|
|
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;
|
|
let (owner, name) = client.owner_repo()?;
|
|
for page in 1..=MAX_PAGES {
|
|
let body = client
|
|
.api()
|
|
.list_action_runs(owner, name, ListActionRunsQuery::default())
|
|
.page(page)
|
|
.page_size(PER_PAGE)
|
|
.send()?;
|
|
let runs = body.workflow_runs.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 ref"));
|
|
}
|
|
}
|
|
}
|
|
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/<n>` tail),
|
|
/// matching the `runs/<n>` the UI shows and `pr-status` surfaces.
|
|
fn run_number_of(run: &ActionRun) -> Option<u64> {
|
|
run.html_url
|
|
.as_ref()
|
|
.and_then(|u| u.as_str().rsplit('/').next())
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
}
|
|
|
|
/// 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 the dispatch
|
|
/// endpoint will reject with a clear 404), 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()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{ActionRun, run_dispatch_target, run_number_of};
|
|
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 parses_run_number_from_html_url() {
|
|
let run = run_from(json!({ "html_url": "http://forge/h/h/actions/runs/750" }));
|
|
assert_eq!(run_number_of(&run), Some(750));
|
|
let run = run_from(json!({ "html_url": "https://forge/o/r/actions/runs/42" }));
|
|
assert_eq!(run_number_of(&run), Some(42));
|
|
assert_eq!(
|
|
run_number_of(&run_from(json!({ "html_url": "http://forge/o/r/x" }))),
|
|
None
|
|
);
|
|
assert_eq!(run_number_of(&run_from(json!({}))), None);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|