416 lines
13 KiB
Rust
416 lines
13 KiB
Rust
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<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, 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<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,
|
|
inputs: &std::collections::BTreeMap<String, String>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
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<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)],
|
|
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"));
|
|
}
|
|
}
|