Add first-class Actions CLI support

This commit is contained in:
Georg Bauer
2026-08-09 16:03:18 +02:00
parent 8ffb20a0d7
commit fd79939751
7 changed files with 531 additions and 54 deletions

302
crates/cli/src/actions.rs Normal file
View File

@@ -0,0 +1,302 @@
use std::error::Error;
use gotcha_gitea::{
ActionJobLog, ActionRunDetails, Client,
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
};
use crate::{
config::{RepositoryScope, Selection},
print_table,
};
const ACTION_HELP: &str = "\
Usage: gotcha action workflow|run SUBCOMMAND
Workflow subcommands:
workflow list [OWNER/REPOSITORY]
List workflows
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY]
Dispatch a workflow on a Git reference
Run subcommands:
run list [OWNER/REPOSITORY] 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";
const WORKFLOW_HELP: &str = "\
Usage: gotcha action workflow SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
dispatch WORKFLOW REF [OWNER/REPOSITORY]";
const RUN_HELP: &str = "\
Usage: gotcha action run SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
show ID [OWNER/REPOSITORY]
logs ID [OWNER/REPOSITORY]";
pub async fn run(
command: &[String],
selection: &Selection,
client: &Client,
) -> Result<bool, Box<dyn Error>> {
match command {
[domain, group, action]
if domain == "action" && group == "workflow" && action == "list" =>
{
print_workflows(&client.action_workflows(&scope(selection, None)?).await?);
}
[domain, group, action, repository]
if domain == "action" && group == "workflow" && action == "list" =>
{
print_workflows(
&client
.action_workflows(&scope(selection, Some(repository))?)
.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]
if domain == "action" && group == "workflow" && action == "dispatch" =>
{
dispatch(
client,
&scope(selection, Some(repository))?,
workflow,
reference,
)
.await?;
}
[domain, group, action] if domain == "action" && group == "run" && action == "list" => {
print_runs(&client.action_runs(&scope(selection, None)?).await?);
}
[domain, group, action, repository]
if domain == "action" && group == "run" && action == "list" =>
{
print_runs(
&client
.action_runs(&scope(selection, Some(repository))?)
.await?,
);
}
[domain, group, action, id] if domain == "action" && group == "run" && action == "show" => {
print_run(
&client
.action_run_details(&scope(selection, None)?, number(id)?)
.await?,
);
}
[domain, group, action, id, repository]
if domain == "action" && group == "run" && action == "show" =>
{
print_run(
&client
.action_run_details(&scope(selection, Some(repository))?, number(id)?)
.await?,
);
}
[domain, group, action, id] if domain == "action" && group == "run" && action == "logs" => {
print_logs(
number(id)?,
&client
.action_run_logs(&scope(selection, None)?, number(id)?)
.await?,
);
}
[domain, group, action, id, repository]
if domain == "action" && group == "run" && action == "logs" =>
{
print_logs(
number(id)?,
&client
.action_run_logs(&scope(selection, Some(repository))?, number(id)?)
.await?,
);
}
[domain, ..] if domain == "action" => return Ok(false),
_ => return Ok(false),
}
Ok(true)
}
pub fn domain_help(domain: &str) -> Option<&'static str> {
(domain == "action").then_some(ACTION_HELP)
}
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
match (domain, command) {
("action", "workflow") => Some(WORKFLOW_HELP),
("action", "run") => Some(RUN_HELP),
_ => None,
}
}
fn scope(selection: &Selection, value: Option<&str>) -> Result<RepositoryScope, Box<dyn Error>> {
match value {
Some(value) => Ok(RepositoryScope::parse(value)?),
None => selection.repository.clone().ok_or_else(|| {
"cannot infer a repository; run inside one or pass OWNER/REPOSITORY".into()
}),
}
}
fn number(value: &str) -> Result<i64, Box<dyn Error>> {
let number = value.parse()?;
if number < 1 {
return Err("run ID must be a positive integer".into());
}
Ok(number)
}
async fn dispatch(
client: &Client,
repository: &RepositoryScope,
workflow: &str,
reference: &str,
) -> Result<(), Box<dyn Error>> {
client
.dispatch_action_workflow(repository, workflow, reference)
.await?;
println!("Dispatched workflow {workflow} on {reference}.");
Ok(())
}
fn print_workflows(workflows: &[ActionWorkflow]) {
print_table(
&[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)],
workflows
.iter()
.map(|workflow| {
vec![
workflow.id.as_deref().unwrap_or("unknown").into(),
workflow.state.as_deref().unwrap_or("unknown").into(),
workflow.name.as_deref().unwrap_or("").into(),
workflow.path.as_deref().unwrap_or("").into(),
]
})
.collect(),
);
}
fn print_runs(runs: &[ActionWorkflowRun]) {
print_table(
&[
("ID", 12),
("RUN", 8),
("STATUS", 14),
("RESULT", 14),
("EVENT", 20),
("BRANCH", 30),
("WORKFLOW", 60),
],
runs.iter()
.map(|run| {
vec![
run.id.unwrap_or_default().to_string(),
run.run_number.unwrap_or_default().to_string(),
run.status.as_deref().unwrap_or("unknown").into(),
run.conclusion.as_deref().unwrap_or("-").into(),
run.event.as_deref().unwrap_or("unknown").into(),
run.head_branch.as_deref().unwrap_or("-").into(),
run.path.as_deref().unwrap_or("").into(),
]
})
.collect(),
);
}
fn print_run(details: &ActionRunDetails) {
let run = &details.run;
println!(
"Run {} #{} [{} / {}] {}",
run.id.unwrap_or_default(),
run.run_number.unwrap_or_default(),
run.status.as_deref().unwrap_or("unknown"),
run.conclusion.as_deref().unwrap_or("-"),
run.display_title.as_deref().unwrap_or("")
);
field("workflow", run.path.as_deref());
field("event", run.event.as_deref());
field("branch", run.head_branch.as_deref());
field("commit", run.head_sha.as_deref());
field("started", run.started_at.as_deref());
field("completed", run.completed_at.as_deref());
field("url", run.html_url.as_deref());
print_jobs(&details.jobs);
}
fn print_jobs(jobs: &[ActionWorkflowJob]) {
println!();
print_table(
&[
("JOB", 12),
("STATUS", 14),
("RESULT", 14),
("RUNNER", 30),
("NAME", 60),
],
jobs.iter()
.map(|job| {
vec![
job.id.unwrap_or_default().to_string(),
job.status.as_deref().unwrap_or("unknown").into(),
job.conclusion.as_deref().unwrap_or("-").into(),
job.runner_name.as_deref().unwrap_or("-").into(),
job.name.as_deref().unwrap_or("").into(),
]
})
.collect(),
);
}
fn print_logs(run: i64, logs: &[ActionJobLog]) {
if logs.is_empty() {
println!("No jobs found for run {run}.");
return;
}
for (index, log) in logs.iter().enumerate() {
if index > 0 {
println!();
}
println!(
"== {} ({}) ==",
log.job.name.as_deref().unwrap_or("job"),
log.job.id.unwrap_or_default()
);
print!("{}", log.text);
if !log.text.ends_with('\n') {
println!();
}
}
}
fn field(name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("{name}: {value}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_run_ids_and_exposes_nested_help() {
assert!(number("0").is_err());
assert_eq!(number("95").unwrap(), 95);
assert!(domain_help("action").unwrap().contains("workflow dispatch"));
assert!(
subcommand_help("action", "run")
.unwrap()
.contains("logs ID")
);
}
}

View File

@@ -1,3 +1,4 @@
mod actions;
mod config;
mod work_items;
@@ -21,6 +22,7 @@ Commands:
issue Work with issues and comments
milestone Work with repository milestones
pull Work with pull requests
action Work with Actions workflows and runs
api Send a raw server API request
Run `gotcha COMMAND` to list that command's subcommands.
@@ -126,6 +128,9 @@ async fn run() -> Result<(), Box<dyn Error>> {
selection.token.as_deref(),
selection.provider,
)?;
if actions::run(&args.command, &selection, &client).await? {
return Ok(());
}
if work_items::run(&args.command, &selection, &client).await? {
return Ok(());
}
@@ -368,7 +373,7 @@ fn domain_help(domain: &str) -> Option<&'static str> {
"user" => Some(USER_HELP),
"repo" => Some(REPO_HELP),
"api" => Some(API_HELP),
_ => work_items::domain_help(domain),
_ => actions::domain_help(domain).or_else(|| work_items::domain_help(domain)),
}
}
@@ -397,7 +402,8 @@ fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
("api", "request") => Some(
"Usage: gotcha api request METHOD ENDPOINT [JSON]\n\nSends an API-relative request using the selected server and token.",
),
_ => work_items::subcommand_help(domain, subcommand),
_ => actions::subcommand_help(domain, subcommand)
.or_else(|| work_items::subcommand_help(domain, subcommand)),
}
}