refactor(hive-forge): port CLI verbs to forgejo-api
This commit is contained in:
parent
b8a3927c43
commit
4636987469
36 changed files with 1463 additions and 1153 deletions
|
|
@ -22,16 +22,16 @@
|
|||
//! - `--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 branch the
|
||||
//! run used.
|
||||
//! `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, so there is no single-job variant.
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::{Value, json};
|
||||
use forgejo_api::structs::{ActionRun, DispatchWorkflowOption, ListActionRunsQuery};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, index};
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
|
|
@ -60,12 +60,12 @@ pub struct Args {
|
|||
///
|
||||
/// 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
|
||||
/// 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, repo, pr)?),
|
||||
(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) => {
|
||||
|
|
@ -73,9 +73,16 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
}
|
||||
};
|
||||
|
||||
let path = format!("/repos/{repo}/actions/workflows/{workflow}/dispatches");
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let body = DispatchWorkflowOption {
|
||||
inputs: None,
|
||||
r#ref: branch.clone(),
|
||||
return_run_info: None,
|
||||
};
|
||||
client
|
||||
.post_no_content(&path, &json!({ "ref": branch }))
|
||||
.api()
|
||||
.dispatch_workflow(owner, name, &workflow, body)
|
||||
.send()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"dispatch workflow {workflow} on {branch} ({repo}) — the workflow \
|
||||
|
|
@ -89,19 +96,21 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
|
||||
/// 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<String> {
|
||||
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)
|
||||
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 `path`.
|
||||
/// carries no workflow file name.
|
||||
fn resolve_run(
|
||||
client: &Client,
|
||||
repo: &str,
|
||||
|
|
@ -110,21 +119,22 @@ fn resolve_run(
|
|||
) -> 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 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();
|
||||
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 head_branch"));
|
||||
.with_context(|| format!("ci-rerun: run #{run_number} has no ref"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -133,24 +143,24 @@ fn resolve_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: &Value) -> Option<u64> {
|
||||
run.get("html_url")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|u| u.rsplit('/').next())
|
||||
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, 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)?;
|
||||
/// 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
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|p| p.rsplit('/').next())
|
||||
.workflow_id
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(fallback_workflow);
|
||||
Some((workflow.to_string(), branch.to_string()))
|
||||
|
|
@ -158,28 +168,41 @@ fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String,
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{run_dispatch_target, run_number_of};
|
||||
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 = json!({ "html_url": "http://forge/h/h/actions/runs/750" });
|
||||
let run = run_from(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" });
|
||||
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(&json!({ "html_url": "http://forge/o/r/x" })),
|
||||
run_number_of(&run_from(json!({ "html_url": "http://forge/o/r/x" }))),
|
||||
None
|
||||
);
|
||||
assert_eq!(run_number_of(&json!({})), None);
|
||||
assert_eq!(run_number_of(&run_from(json!({}))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_workflow_and_branch() {
|
||||
let run = json!({
|
||||
"head_branch": "atlas/foo",
|
||||
"path": ".forgejo/workflows/ci.yml",
|
||||
});
|
||||
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()))
|
||||
|
|
@ -187,8 +210,8 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_workflow_without_path() {
|
||||
let run = json!({ "head_branch": "b" });
|
||||
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()))
|
||||
|
|
@ -196,7 +219,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn no_branch_means_no_target() {
|
||||
assert_eq!(run_dispatch_target(&json!({}), "ci.yml"), None);
|
||||
fn no_ref_means_no_target() {
|
||||
assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue