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

51
Cargo.lock generated
View File

@@ -1333,12 +1333,6 @@ dependencies = [
"bitflags 2.13.1", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -1706,19 +1700,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.14.0" version = "1.14.0"
@@ -1787,15 +1768,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" 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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.11" version = "0.11.11"
@@ -2659,14 +2631,11 @@ dependencies = [
"flate2", "flate2",
"fnv", "fnv",
"once_cell", "once_cell",
"plist",
"regex-syntax", "regex-syntax",
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json",
"thiserror 2.0.19", "thiserror 2.0.19",
"walkdir", "walkdir",
"yaml-rust",
] ]
[[package]] [[package]]
@@ -2820,7 +2789,6 @@ dependencies = [
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
"time-macros",
] ]
[[package]] [[package]]
@@ -2829,16 +2797,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" 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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -3652,15 +3610,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 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]] [[package]]
name = "yansi" name = "yansi"
version = "1.0.1" version = "1.0.1"

View File

@@ -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 -- issue close 7
cargo run -p gotcha-cli -- milestone list cargo run -p gotcha-cli -- milestone list
cargo run -p gotcha-cli -- pull 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 GET repos/owner/project/issues
cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}' cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}'
``` ```

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

213
crates/gitea/src/actions.rs Normal file
View File

@@ -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<models::ActionWorkflowJob>,
}
#[derive(Debug)]
pub struct ActionJobLog {
pub job: models::ActionWorkflowJob,
pub text: String,
}
impl Client {
pub async fn action_workflows(
&self,
repository: &RepositoryId,
) -> Result<Vec<models::ActionWorkflow>> {
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<Vec<models::ActionWorkflowRun>> {
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<ActionRunDetails> {
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<Vec<ActionJobLog>> {
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<i32> {
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"));
}
}

View File

@@ -11,6 +11,7 @@ use gitea_openapi::apis;
pub use gitea_openapi::models; pub use gitea_openapi::models;
pub use models::{Repository, ServerVersion, User}; pub use models::{Repository, ServerVersion, User};
mod actions;
pub mod activity; pub mod activity;
mod config; mod config;
pub mod diff; pub mod diff;
@@ -20,6 +21,7 @@ mod milestones;
mod pulls; mod pulls;
mod repositories; mod repositories;
pub use actions::{ActionJobLog, ActionRunDetails};
pub use activity::ActivityFilter; pub use activity::ActivityFilter;
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url}; pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{ pub use domain::{

View File

@@ -14,6 +14,6 @@ path = "src/main.rs"
crossterm = "0.29" crossterm = "0.29"
gotcha_gitea = { path = "../gitea" } gotcha_gitea = { path = "../gitea" }
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } 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 tokio.workspace = true
tui-markdown = { version = "0.3", default-features = false } tui-markdown = { version = "0.3", default-features = false }