diff --git a/Cargo.lock b/Cargo.lock index c672c6f..8de5a03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1333,12 +1333,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1706,19 +1700,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "plist" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - [[package]] name = "portable-atomic" version = "1.14.0" @@ -1787,15 +1768,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.11" @@ -2659,14 +2631,11 @@ dependencies = [ "flate2", "fnv", "once_cell", - "plist", "regex-syntax", "serde", "serde_derive", - "serde_json", "thiserror 2.0.19", "walkdir", - "yaml-rust", ] [[package]] @@ -2820,7 +2789,6 @@ dependencies = [ "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -2829,16 +2797,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -3652,15 +3610,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/README.md b/README.md index 7eb1eaf..43132c4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ cargo run -p gotcha-cli -- issue show 7 cargo run -p gotcha-cli -- issue close 7 cargo run -p gotcha-cli -- milestone list cargo run -p gotcha-cli -- pull list +cargo run -p gotcha-cli -- action workflow list +cargo run -p gotcha-cli -- action workflow dispatch dependency-audit.yml main +cargo run -p gotcha-cli -- action run list +cargo run -p gotcha-cli -- action run show 95 +cargo run -p gotcha-cli -- action run logs 95 cargo run -p gotcha-cli -- api request GET repos/owner/project/issues cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}' ``` diff --git a/crates/cli/src/actions.rs b/crates/cli/src/actions.rs new file mode 100644 index 0000000..b825f71 --- /dev/null +++ b/crates/cli/src/actions.rs @@ -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> { + 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> { + 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, +) -> Result<(), Box> { + 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") + ); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 37800a1..6448922 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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> { 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)), } } diff --git a/crates/gitea/src/actions.rs b/crates/gitea/src/actions.rs new file mode 100644 index 0000000..0c5a904 --- /dev/null +++ b/crates/gitea/src/actions.rs @@ -0,0 +1,213 @@ +use reqwest::header::ACCEPT; + +use crate::{Client, Error, Method, RepositoryId, Result, models, positive}; +use gitea_openapi::apis; + +#[derive(Debug)] +pub struct ActionRunDetails { + pub run: models::ActionWorkflowRun, + pub jobs: Vec, +} + +#[derive(Debug)] +pub struct ActionJobLog { + pub job: models::ActionWorkflowJob, + pub text: String, +} + +impl Client { + pub async fn action_workflows( + &self, + repository: &RepositoryId, + ) -> Result> { + apis::repository_api::actions_list_repository_workflows( + &self.configuration(), + &repository.owner, + &repository.repository, + ) + .await + .map(|response| response.workflows.unwrap_or_default()) + .map_err(Error::generated) + } + + pub async fn dispatch_action_workflow( + &self, + repository: &RepositoryId, + workflow: &str, + reference: &str, + ) -> Result<()> { + if workflow.is_empty() || reference.is_empty() { + return Err(Error::InvalidInput( + "workflow and reference must not be empty".into(), + )); + } + apis::repository_api::actions_dispatch_workflow( + &self.configuration(), + &repository.owner, + &repository.repository, + workflow, + Some(models::CreateActionWorkflowDispatch::new(reference.into())), + ) + .await + .map_err(Error::generated) + } + + pub async fn action_runs( + &self, + repository: &RepositoryId, + ) -> Result> { + apis::repository_api::get_workflow_runs( + &self.configuration(), + &repository.owner, + &repository.repository, + None, + None, + None, + None, + None, + Some(1), + Some(100), + ) + .await + .map(|response| response.workflow_runs.unwrap_or_default()) + .map_err(Error::generated) + } + + pub async fn action_run_details( + &self, + repository: &RepositoryId, + run: i64, + ) -> Result { + positive(run, "run ID")?; + let run_id = action_run_id(run)?; + let (run, jobs) = tokio::try_join!( + async { + apis::repository_api::get_workflow_run( + &self.configuration(), + &repository.owner, + &repository.repository, + &run.to_string(), + ) + .await + .map_err(Error::generated) + }, + async { + apis::repository_api::list_workflow_run_jobs( + &self.configuration(), + &repository.owner, + &repository.repository, + run_id, + None, + Some(1), + Some(100), + ) + .await + .map_err(Error::generated) + } + )?; + Ok(ActionRunDetails { + run, + jobs: jobs.jobs.unwrap_or_default(), + }) + } + + pub async fn action_run_logs( + &self, + repository: &RepositoryId, + run: i64, + ) -> Result> { + let run = action_run_id(run)?; + let jobs = apis::repository_api::list_workflow_run_jobs( + &self.configuration(), + &repository.owner, + &repository.repository, + run, + None, + Some(1), + Some(100), + ) + .await + .map_err(Error::generated)? + .jobs + .unwrap_or_default(); + let mut logs = Vec::with_capacity(jobs.len()); + for job in jobs { + let id = job + .id + .ok_or_else(|| Error::Generated("Action job has no ID.".into()))?; + let endpoint = format!( + "repos/{}/{}/actions/jobs/{id}/logs", + apis::urlencode(&repository.owner), + apis::urlencode(&repository.repository), + ); + let text = self + .execute( + self.request(Method::GET, &endpoint)? + .header(ACCEPT, "text/plain"), + ) + .await? + .text() + .await?; + logs.push(ActionJobLog { job, text }); + } + Ok(logs) + } +} + +fn action_run_id(run: i64) -> Result { + positive(run, "run ID")?; + i32::try_from(run).map_err(|_| Error::InvalidInput("run ID is too large".into())) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; + + use super::*; + use crate::Provider; + + #[tokio::test] + async fn gets_every_job_log_for_a_run() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let mut requests = Vec::new(); + for (content_type, body) in [ + ( + "application/json", + r#"{"total_count":1,"jobs":[{"id":139,"name":"audit"}]}"#, + ), + ("text/plain", "scanner output\nexit code 1\n"), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0; 4096]; + let length = stream.read(&mut buffer).unwrap(); + requests.push(String::from_utf8_lossy(&buffer[..length]).into_owned()); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + requests + }); + + let client = + Client::with_provider(&format!("http://{address}"), None, Provider::Gitea).unwrap(); + let repository = RepositoryId::new("hugo", "Gotcha").unwrap(); + let logs = client.action_run_logs(&repository, 95).await.unwrap(); + + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].job.name.as_deref(), Some("audit")); + assert_eq!(logs[0].text, "scanner output\nexit code 1\n"); + let requests = server.join().unwrap(); + assert!(requests[0].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/runs/95/jobs?")); + assert!(requests[1].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/jobs/139/logs ")); + assert!(requests[1].contains("accept: text/plain")); + } +} diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index 1bb4542..9be1e55 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -11,6 +11,7 @@ use gitea_openapi::apis; pub use gitea_openapi::models; pub use models::{Repository, ServerVersion, User}; +mod actions; pub mod activity; mod config; pub mod diff; @@ -20,6 +21,7 @@ mod milestones; mod pulls; mod repositories; +pub use actions::{ActionJobLog, ActionRunDetails}; pub use activity::ActivityFilter; pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url}; pub use domain::{ diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 0be53ae..fdc4afd 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -14,6 +14,6 @@ path = "src/main.rs" crossterm = "0.29" gotcha_gitea = { path = "../gitea" } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } -syntect = { version = "5.3", default-features = false, features = ["default-fancy"] } +syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } tokio.workspace = true tui-markdown = { version = "0.3", default-features = false }