use std::error::Error; use gotcha_gitea::{ ActionJobLog, ActionRunDetails, ActionRunQuery, Client, models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun}, parse_action_inputs, }; 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] [--input KEY=VALUE]… Dispatch a workflow on a Git reference Run subcommands: 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"; const WORKFLOW_HELP: &str = "\ Usage: gotcha action workflow SUBCOMMAND Subcommands: list [OWNER/REPOSITORY] dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…"; const RUN_HELP: &str = "\ Usage: gotcha action run SUBCOMMAND Subcommands: 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]"; pub async fn run( command: &[String], selection: &Selection, client: &Client, ) -> Result> { 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, arguments @ ..] if domain == "action" && group == "workflow" && action == "dispatch" => { let arguments = dispatch_arguments(arguments, selection)?; dispatch( client, &arguments.repository, &arguments.workflow, &arguments.reference, &arguments.inputs, ) .await?; } [domain, group, action, arguments @ ..] if domain == "action" && group == "run" && action == "list" => { 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( &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> { 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> { 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, inputs: &std::collections::BTreeMap, ) -> Result<(), Box> { client .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, } fn dispatch_arguments( arguments: &[String], selection: &Selection, ) -> Result> { 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> { 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> { value .parse::() .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)], 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") ); } #[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")); } }