Add first-class Actions management (#63)

This commit is contained in:
Georg Bauer
2026-08-15 20:06:52 +02:00
parent f84ab774e6
commit 59bcddfadf
32 changed files with 4039 additions and 275 deletions

View File

@@ -1,8 +1,9 @@
use std::error::Error;
use gotcha_gitea::{
ActionJobLog, ActionRunDetails, Client,
ActionJobLog, ActionRunDetails, ActionRunQuery, Client,
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
parse_action_inputs,
};
use crate::{
@@ -16,11 +17,14 @@ Usage: gotcha action workflow|run SUBCOMMAND
Workflow subcommands:
workflow list [OWNER/REPOSITORY]
List workflows
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY]
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…
Dispatch a workflow on a Git reference
Run subcommands:
run list [OWNER/REPOSITORY] List workflow runs
run list [OWNER/REPOSITORY] [--page N] [--limit N]
[--status STATUS] [--event EVENT] [--branch BRANCH]
[--actor LOGIN] [--sha SHA]
List workflow runs
run show ID [OWNER/REPOSITORY] Show a run and its jobs
run logs ID [OWNER/REPOSITORY] Print every job log for a run";
@@ -29,13 +33,14 @@ Usage: gotcha action workflow SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
dispatch WORKFLOW REF [OWNER/REPOSITORY]";
dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…";
const RUN_HELP: &str = "\
Usage: gotcha action run SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
list [OWNER/REPOSITORY] [--page N] [--limit N] [--status STATUS]
[--event EVENT] [--branch BRANCH] [--actor LOGIN] [--sha SHA]
show ID [OWNER/REPOSITORY]
logs ID [OWNER/REPOSITORY]";
@@ -59,33 +64,24 @@ pub async fn run(
.await?,
);
}
[domain, group, action, workflow, reference]
if domain == "action" && group == "workflow" && action == "dispatch" =>
{
dispatch(client, &scope(selection, None)?, workflow, reference).await?;
}
[domain, group, action, workflow, reference, repository]
[domain, group, action, arguments @ ..]
if domain == "action" && group == "workflow" && action == "dispatch" =>
{
let arguments = dispatch_arguments(arguments, selection)?;
dispatch(
client,
&scope(selection, Some(repository))?,
workflow,
reference,
&arguments.repository,
&arguments.workflow,
&arguments.reference,
&arguments.inputs,
)
.await?;
}
[domain, group, action] if domain == "action" && group == "run" && action == "list" => {
print_runs(&client.action_runs(&scope(selection, None)?).await?);
}
[domain, group, action, repository]
[domain, group, action, arguments @ ..]
if domain == "action" && group == "run" && action == "list" =>
{
print_runs(
&client
.action_runs(&scope(selection, Some(repository))?)
.await?,
);
let (repository, query) = run_list_arguments(arguments, selection)?;
print_runs(&client.action_runs(&repository, &query).await?.items);
}
[domain, group, action, id] if domain == "action" && group == "run" && action == "show" => {
print_run(
@@ -161,14 +157,96 @@ async fn dispatch(
repository: &RepositoryScope,
workflow: &str,
reference: &str,
inputs: &std::collections::BTreeMap<String, String>,
) -> Result<(), Box<dyn Error>> {
client
.dispatch_action_workflow(repository, workflow, reference)
.dispatch_action_workflow(repository, workflow, reference, inputs)
.await?;
println!("Dispatched workflow {workflow} on {reference}.");
Ok(())
}
struct DispatchArguments {
repository: RepositoryScope,
workflow: String,
reference: String,
inputs: std::collections::BTreeMap<String, String>,
}
fn dispatch_arguments(
arguments: &[String],
selection: &Selection,
) -> Result<DispatchArguments, Box<dyn Error>> {
let [workflow, reference, remaining @ ..] = arguments else {
return Err("usage: gotcha action workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…".into());
};
let mut repository = None;
let mut inputs = Vec::new();
let mut index = 0;
while index < remaining.len() {
if remaining[index] == "--input" {
index += 1;
inputs.push(
remaining
.get(index)
.ok_or("--input requires KEY=VALUE")?
.clone(),
);
} else if remaining[index].starts_with('-') {
return Err(format!("unknown dispatch option: {}", remaining[index]).into());
} else if repository.replace(remaining[index].as_str()).is_some() {
return Err("only one OWNER/REPOSITORY may be supplied".into());
}
index += 1;
}
Ok(DispatchArguments {
repository: scope(selection, repository)?,
workflow: workflow.clone(),
reference: reference.clone(),
inputs: parse_action_inputs(&inputs)?,
})
}
fn run_list_arguments(
arguments: &[String],
selection: &Selection,
) -> Result<(RepositoryScope, ActionRunQuery), Box<dyn Error>> {
let mut repository = None;
let mut query = ActionRunQuery::default();
let mut index = 0;
while index < arguments.len() {
let argument = &arguments[index];
if argument.starts_with('-') {
index += 1;
let value = arguments
.get(index)
.ok_or_else(|| format!("{argument} requires a value"))?;
match argument.as_str() {
"--page" => query.page = positive_number(value, "page")?,
"--limit" => query.limit = positive_number(value, "limit")?,
"--status" => query.status = Some(value.clone()),
"--event" => query.event = Some(value.clone()),
"--branch" => query.branch = Some(value.clone()),
"--actor" => query.actor = Some(value.clone()),
"--sha" => query.head_sha = Some(value.clone()),
_ => return Err(format!("unknown run-list option: {argument}").into()),
}
} else if repository.replace(argument.as_str()).is_some() {
return Err("only one OWNER/REPOSITORY may be supplied".into());
}
index += 1;
}
Ok((scope(selection, repository)?, query))
}
fn positive_number(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
value
.parse::<i32>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| format!("{name} must be a positive integer").into())
}
fn print_workflows(workflows: &[ActionWorkflow]) {
print_table(
&[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)],
@@ -299,4 +377,39 @@ mod tests {
.contains("logs ID")
);
}
#[test]
fn parses_dispatch_inputs_and_run_filters() {
let selection = Selection {
name: Some("gitea".into()),
url: "https://gitea.example.com".into(),
token: Some("secret".into()),
provider: gotcha_gitea::Provider::Gitea,
repository: Some(RepositoryScope::parse("hugo/Gotcha").unwrap()),
};
let dispatch = dispatch_arguments(
&[
"release.yml".into(),
"main".into(),
"--input".into(),
"channel=stable".into(),
],
&selection,
)
.unwrap();
assert_eq!(dispatch.inputs["channel"], "stable");
let (_, query) = run_list_arguments(
&[
"--page".into(),
"2".into(),
"--status".into(),
"in_progress".into(),
],
&selection,
)
.unwrap();
assert_eq!(query.page, 2);
assert_eq!(query.status.as_deref(), Some("in_progress"));
}
}