Add first-class Actions CLI support
This commit is contained in:
51
Cargo.lock
generated
51
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
302
crates/cli/src/actions.rs
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
213
crates/gitea/src/actions.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user