use super::*;
use pinto::automation::{AutomationCommandResult, AutomationPlan, AutomationReport};
use std::io::Read;
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command as ProcessCommand;
pub(super) async fn cmd_automate(args: AutomateArgs) -> anyhow::Result<ExitCode> {
if args.schema {
println!(
"{}",
serde_json::to_string_pretty(&AutomationPlan::json_schema())?
);
return Ok(ExitCode::SUCCESS);
}
let source = args.plan.ok_or(Error::InvalidAutomationPlan)?;
let input = read_automation_plan(&source).await?;
let plan = AutomationPlan::parse(&input).map_err(|_| Error::InvalidAutomationPlan)?;
let validated = validate_automation_commands(&plan);
if validated.iter().any(|command| command.error.is_some()) {
let commands = validated
.iter()
.map(|command| AutomationCommandResult {
index: command.index,
command: command.name.clone(),
status: if command.error.is_some() {
"invalid".to_string()
} else {
"valid".to_string()
},
created_ids: Vec::new(),
updated_ids: automation_target_ids(&command.argv),
error: command.error.clone(),
})
.collect();
let report = AutomationReport {
status: "invalid".to_string(),
dry_run: args.dry_run,
commands,
};
if args.json {
print_automation_json(&report)?;
} else {
print_automation_validation(&report, false);
}
return Ok(ExitCode::from(1));
}
if args.dry_run {
let dir = std::env::current_dir()?;
let report = dry_run_automation(&dir, &validated).await?;
if args.json {
print_automation_json(&report)?;
} else {
print_automation_validation(&report, report.status == "dry_run");
}
return Ok(if report.status == "dry_run" {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
});
}
let dir = std::env::current_dir()?;
let mut results = Vec::with_capacity(validated.len());
let mut internal_failure = false;
let mut failed_at = None;
for (position, command) in validated.iter().enumerate() {
let execution = run_automation_command(&dir, &command.argv).await?;
if execution.success {
if !args.json {
print!("{}", execution.stdout);
}
results.push(automation_execution_result(
command,
&execution,
"succeeded",
));
} else {
internal_failure = execution.exit_code != Some(1);
results.push(automation_execution_result(command, &execution, "failed"));
failed_at = Some(position);
for skipped in validated.iter().skip(position + 1) {
results.push(AutomationCommandResult {
index: skipped.index,
command: skipped.name.clone(),
status: "skipped".to_string(),
created_ids: Vec::new(),
updated_ids: automation_target_ids(&skipped.argv),
error: Some(current().text(Message::AutomationNotExecutedAfterFailure)),
});
}
break;
}
}
let failed = failed_at.is_some();
let report = AutomationReport {
status: if failed {
"partial_failure".to_string()
} else {
"completed".to_string()
},
dry_run: false,
commands: results,
};
if args.json {
print_automation_json(&report)?;
} else if let Some(failed_at) = failed_at {
let failed_command = &report.commands[failed_at];
let index = failed_command.index.to_string();
let completed = report
.commands
.iter()
.filter(|command| command.status == "succeeded")
.count()
.to_string();
let failed_count = report
.commands
.iter()
.filter(|command| command.status == "failed")
.count()
.to_string();
let skipped = report
.commands
.iter()
.filter(|command| command.status == "skipped")
.count()
.to_string();
let error = failed_command.error.as_deref().unwrap_or("unknown error");
eprintln!(
"{}",
current().format(
Message::AutomationCommandFailed,
[
("index", index.as_str()),
("command", failed_command.command.as_str()),
("error", error),
],
)
);
eprintln!(
"{}",
current().format(
Message::AutomationPartialFailure,
[
("index", index.as_str()),
("command", failed_command.command.as_str()),
("completed", completed.as_str()),
("failed", failed_count.as_str()),
("skipped", skipped.as_str()),
],
)
);
for skipped_command in report
.commands
.iter()
.filter(|command| command.status == "skipped")
{
let skipped_index = skipped_command.index.to_string();
eprintln!(
"{}",
current().format(
Message::AutomationCommandSkipped,
[
("index", skipped_index.as_str()),
("command", skipped_command.command.as_str()),
],
)
);
}
} else {
let total = report.commands.len().to_string();
println!(
"{}",
current().format(Message::AutomationCompleted, [("total", total.as_str())])
);
}
Ok(if failed {
if internal_failure {
ExitCode::from(2)
} else {
ExitCode::from(1)
}
} else {
ExitCode::SUCCESS
})
}
#[derive(Debug)]
pub(super) struct ValidatedAutomationCommand {
pub(super) index: usize,
pub(super) argv: Vec<String>,
pub(super) name: String,
pub(super) error: Option<String>,
}
#[derive(Debug)]
pub(super) struct AutomationExecution {
pub(super) success: bool,
pub(super) exit_code: Option<i32>,
pub(super) stdout: String,
pub(super) stderr: String,
}
pub(super) async fn read_automation_plan(source: &str) -> anyhow::Result<String> {
if source == "-" {
let input = tokio::task::spawn_blocking(|| {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
Ok::<String, std::io::Error>(input)
})
.await??;
return Ok(input);
}
let inline_json = source.trim_start().starts_with('{');
let path = Path::new(source);
let exists = match tokio::fs::try_exists(path).await {
Ok(exists) => exists,
Err(_error) if inline_json => return Ok(source.to_string()),
Err(error) => {
return Err(Error::AutomationPlanSource {
path: path.to_path_buf(),
message: error.to_string(),
}
.into());
}
};
if exists {
return tokio::fs::read_to_string(path).await.map_err(|error| {
Error::AutomationPlanSource {
path: path.to_path_buf(),
message: error.to_string(),
}
.into()
});
}
if inline_json {
return Ok(source.to_string());
}
Err(Error::AutomationPlanSource {
path: path.to_path_buf(),
message: "file does not exist".to_string(),
}
.into())
}
fn validate_automation_commands(plan: &AutomationPlan) -> Vec<ValidatedAutomationCommand> {
plan.commands()
.iter()
.enumerate()
.map(|(position, argv)| {
let parsed =
Cli::try_parse_from(std::iter::once("pinto".to_string()).chain(argv.clone()));
let error = match parsed {
Err(_) => Some(current().text(Message::AutomationInvalidCommandArguments)),
Ok(cli) => validate_automation_item_ids(&cli),
};
ValidatedAutomationCommand {
index: position + 1,
argv: argv.clone(),
name: automation_command_name(argv),
error,
}
})
.collect()
}
fn validate_automation_item_ids(cli: &Cli) -> Option<String> {
let ids: Vec<&String> = match &cli.command {
Command::Add(args) => args.parent.iter().chain(args.depends_on.iter()).collect(),
Command::Show(args) => args.ids.iter().collect(),
Command::Move(args) => args
.destination_and_ids()
.map_or_else(Vec::new, |(_, ids)| ids.iter().collect()),
Command::Reorder(args) => {
let mut ids = vec![&args.id];
if let Some(reference) = &args.before {
ids.push(reference);
}
if let Some(reference) = &args.after {
ids.push(reference);
}
ids
}
Command::Edit(args) => {
let mut ids = vec![&args.id];
if let Some(parent) = &args.parent {
ids.push(parent);
}
ids
}
Command::Remove(args) => args.ids.iter().collect(),
Command::Restore(args) => vec![&args.id],
Command::Dep(args) => match &args.command {
DepCommand::Add { id, depends_on } | DepCommand::Rm { id, depends_on } => {
vec![id, depends_on]
}
},
Command::Link(args) => match &args.command {
LinkCommand::Add { id, .. } | LinkCommand::Rm { id, .. } => vec![id],
LinkCommand::Sync { .. } => Vec::new(),
},
Command::Sprint(args) => match &args.command {
SprintCommand::Add { item_id, .. } => item_id.iter().collect(),
SprintCommand::Unassign { item_id, .. } => vec![item_id],
SprintCommand::New { .. }
| SprintCommand::Edit { .. }
| SprintCommand::Remove { .. }
| SprintCommand::Start { .. }
| SprintCommand::Close { .. }
| SprintCommand::List { .. }
| SprintCommand::Burndown { .. }
| SprintCommand::Velocity { .. }
| SprintCommand::Capacity { .. } => Vec::new(),
},
Command::Init
| Command::List(_)
| Command::Next(_)
| Command::Dod(_)
| Command::Export(_)
| Command::Board(_)
| Command::CycleTime(_)
| Command::Rebalance(_)
| Command::Migrate(_)
| Command::Doctor(_)
| Command::Automate(_)
| Command::Shell
| Command::Kanban(_)
| Command::Completion(_) => Vec::new(),
};
ids.into_iter().find_map(|raw| {
raw.parse::<ItemId>()
.err()
.map(|error| error.localized(current()))
})
}
async fn run_automation_command(
dir: &Path,
argv: &[String],
) -> anyhow::Result<AutomationExecution> {
let executable = std::env::current_exe()?;
let output = ProcessCommand::new(executable)
.args(argv)
.current_dir(dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
Ok(AutomationExecution {
success: output.status.success(),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
async fn dry_run_automation(
dir: &Path,
commands: &[ValidatedAutomationCommand],
) -> anyhow::Result<AutomationReport> {
let _lock = lock_board(dir).await?;
let workspace = create_dry_run_workspace(dir).await?;
let report = run_dry_run_commands(&workspace, commands).await;
let cleanup = tokio::fs::remove_dir_all(&workspace).await;
match report {
Err(error) => {
let _ = cleanup;
Err(error)
}
Ok(report) => {
cleanup?;
Ok(report)
}
}
}
async fn run_dry_run_commands(
workspace: &Path,
commands: &[ValidatedAutomationCommand],
) -> anyhow::Result<AutomationReport> {
let mut results = Vec::with_capacity(commands.len());
let mut failed = false;
for (position, command) in commands.iter().enumerate() {
let execution = run_automation_command(workspace, &command.argv).await?;
if execution.success {
results.push(automation_execution_result(command, &execution, "valid"));
} else {
results.push(automation_execution_result(command, &execution, "invalid"));
for skipped in commands.iter().skip(position + 1) {
results.push(AutomationCommandResult {
index: skipped.index,
command: skipped.name.clone(),
status: "skipped".to_string(),
created_ids: Vec::new(),
updated_ids: automation_target_ids(&skipped.argv),
error: Some(current().text(Message::AutomationNotValidatedAfterFailure)),
});
}
failed = true;
break;
}
}
Ok(AutomationReport {
status: if failed {
"invalid".to_string()
} else {
"dry_run".to_string()
},
dry_run: true,
commands: results,
})
}
async fn create_dry_run_workspace(dir: &Path) -> anyhow::Result<std::path::PathBuf> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let base = std::env::temp_dir();
for attempt in 0..100_u32 {
let workspace = base.join(format!(
"pinto-dry-run-{}-{timestamp}-{attempt}",
std::process::id()
));
match tokio::fs::create_dir(&workspace).await {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(error) = tokio::fs::set_permissions(
&workspace,
std::fs::Permissions::from_mode(0o700),
)
.await
{
let _ = tokio::fs::remove_dir_all(&workspace).await;
return Err(error.into());
}
}
let source = dir.join(".pinto");
let destination = workspace.join(".pinto");
if let Err(error) = copy_directory(&source, &destination).await {
let _ = tokio::fs::remove_dir_all(&workspace).await;
return Err(error);
}
let has_git = match tokio::fs::try_exists(dir.join(".git")).await {
Ok(value) => value,
Err(error) => {
let _ = tokio::fs::remove_dir_all(&workspace).await;
return Err(error.into());
}
};
if has_git {
let output = match ProcessCommand::new("git")
.args(["init"])
.current_dir(&workspace)
.output()
.await
{
Ok(output) => output,
Err(error) => {
let _ = tokio::fs::remove_dir_all(&workspace).await;
let message = current().format(
Message::AutomationDryRunGitInitFailed,
[("message", error.to_string().as_str())],
);
return Err(anyhow::anyhow!("{message}"));
}
};
if !output.status.success() {
let _ = tokio::fs::remove_dir_all(&workspace).await;
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = current().format(
Message::AutomationDryRunGitInitFailed,
[("message", detail.as_str())],
);
return Err(anyhow::anyhow!("{message}"));
}
}
return Ok(workspace);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
Err(anyhow::anyhow!(
"{}",
current().text(Message::AutomationDryRunWorkspaceUnavailable)
))
}
async fn copy_directory(source: &Path, destination: &Path) -> anyhow::Result<()> {
let mut pending = vec![(source.to_path_buf(), destination.to_path_buf())];
while let Some((source, destination)) = pending.pop() {
tokio::fs::create_dir_all(&destination).await?;
let mut entries = tokio::fs::read_dir(&source).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_name() == ".lock" {
continue;
}
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if entry.file_type().await?.is_dir() {
pending.push((source_path, destination_path));
} else {
tokio::fs::copy(source_path, destination_path).await?;
}
}
}
Ok(())
}
pub(super) fn automation_command_name(argv: &[String]) -> String {
match (argv.first(), argv.get(1)) {
(Some(command), Some(subcommand))
if matches!(command.as_str(), "dep" | "link" | "sprint") =>
{
format!("{command} {subcommand}")
}
(Some(command), _) => command.clone(),
(None, _) => "unknown".to_string(),
}
}
pub(super) fn parsed_item_id(raw: Option<&String>) -> Option<String> {
raw.and_then(|value| value.parse::<ItemId>().ok())
.map(|id| id.to_string())
}
pub(super) fn automation_target_ids(argv: &[String]) -> Vec<String> {
let Some(command) = argv.first().map(String::as_str) else {
return Vec::new();
};
match command {
"move" => argv
.iter()
.skip(1)
.filter_map(|value| parsed_item_id(Some(value)))
.collect(),
"edit" | "reorder" => parsed_item_id(argv.get(1)).into_iter().collect(),
"remove" => argv
.iter()
.skip(1)
.filter_map(|value| parsed_item_id(Some(value)))
.collect(),
"dep" | "link" => parsed_item_id(argv.get(2)).into_iter().collect(),
"sprint" => parsed_item_id(argv.get(3)).into_iter().collect(),
_ => Vec::new(),
}
}
pub(super) fn first_item_id_in_output(output: &str) -> Option<String> {
output.split_whitespace().find_map(|token| {
let token = token.trim_matches(|character: char| {
!character.is_ascii_alphanumeric() && character != '-' && character != '_'
});
token.parse::<ItemId>().ok().map(|id| id.to_string())
})
}
pub(super) fn automation_execution_result(
command: &ValidatedAutomationCommand,
execution: &AutomationExecution,
status: &str,
) -> AutomationCommandResult {
let created_ids = (command.argv.first().map(String::as_str) == Some("add"))
.then(|| first_item_id_in_output(&execution.stdout))
.flatten()
.into_iter()
.collect();
AutomationCommandResult {
index: command.index,
command: command.name.clone(),
status: status.to_string(),
created_ids,
updated_ids: automation_target_ids(&command.argv),
error: (!execution.success).then(|| {
let error = execution.stderr.trim();
if error.is_empty() {
let status = execution
.exit_code
.map_or_else(|| "unknown".to_string(), |code| code.to_string());
current().format(
Message::AutomationCommandExited,
[("status", status.as_str())],
)
} else {
error.to_string()
}
}),
}
}
fn print_automation_json(report: &AutomationReport) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(report)?);
Ok(())
}
fn print_automation_validation(report: &AutomationReport, dry_run: bool) {
for command in &report.commands {
let index = command.index.to_string();
let message = if command.status == "invalid" {
Message::AutomationCommandInvalid
} else {
Message::AutomationCommandValid
};
eprintln!(
"{}",
current().format(
message,
[
("index", index.as_str()),
("command", command.command.as_str())
],
)
);
}
if dry_run {
let total = report.commands.len().to_string();
println!(
"{}",
current().format(
Message::AutomationDryRunCompleted,
[("total", total.as_str())]
)
);
}
}