From e574d99764f50daf4246ab13d1545d69b440436a Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 25 Sep 2025 11:03:17 +0300 Subject: [PATCH] feat(task): list tasks, get progress and set stage via CLI (#38471) --- cli/Cargo.lock | 75 +++---- cli/Cargo.toml | 1 + cli/src/commands/mod.rs | 32 ++- cli/src/commands/sourcemap/upload.rs | 17 +- cli/src/commands/tasks/list.rs | 202 ++++++++++++++++++ cli/src/commands/tasks/mod.rs | 141 +++++++++++++ cli/src/commands/tasks/progress.rs | 178 ++++++++++++++++ cli/src/commands/tasks/update_stage.rs | 183 ++++++++++++++++ cli/src/commands/tasks/utils.rs | 281 +++++++++++++++++++++++++ cli/src/tui/query.rs | 1 + cli/src/utils/client.rs | 14 ++ cli/src/utils/mod.rs | 1 + cli/src/utils/release.rs | 7 +- 13 files changed, 1079 insertions(+), 54 deletions(-) create mode 100644 cli/src/commands/tasks/list.rs create mode 100644 cli/src/commands/tasks/mod.rs create mode 100644 cli/src/commands/tasks/progress.rs create mode 100644 cli/src/commands/tasks/update_stage.rs create mode 100644 cli/src/commands/tasks/utils.rs create mode 100644 cli/src/utils/client.rs diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 6485a35d76..becdd0f9ff 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "atomic-waker" @@ -275,9 +275,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -285,9 +285,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -951,7 +951,7 @@ dependencies = [ "http 1.3.1", "hyper 1.7.0", "hyper-util", - "rustls 0.23.31", + "rustls 0.23.32", "rustls-pki-types", "tokio", "tokio-rustls 0.26.3", @@ -1228,9 +1228,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -1244,9 +1244,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libredox" @@ -1532,6 +1532,7 @@ name = "posthog-cli" version = "0.4.8" dependencies = [ "anyhow", + "chrono", "clap", "crossterm 0.28.1", "dirs", @@ -1622,7 +1623,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.31", + "rustls 0.23.32", "socket2 0.6.0", "thiserror 2.0.16", "tokio", @@ -1642,7 +1643,7 @@ dependencies = [ "rand", "ring", "rustc-hash", - "rustls 0.23.31", + "rustls 0.23.32", "rustls-pki-types", "slab", "thiserror 2.0.16", @@ -1869,7 +1870,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.31", + "rustls 0.23.32", "rustls-pki-types", "serde", "serde_json", @@ -1953,9 +1954,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "once_cell", "ring", @@ -2050,9 +2051,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", @@ -2060,18 +2061,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -2490,7 +2491,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ - "rustls 0.23.31", + "rustls 0.23.32", "tokio", ] @@ -2806,9 +2807,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", @@ -2819,9 +2820,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -2833,9 +2834,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -2846,9 +2847,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2856,9 +2857,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -2869,18 +2870,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 806c9f30ed..a07891ecb7 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,6 +22,7 @@ path = "src/main.rs" clap = { version = "4.5.31", features = ["derive"] } dirs = "6.0.0" inquire = "0.7.5" +chrono = "0.4.42" posthog-symbol-data = "0.2.0" walkdir = "2.5.0" globset = "0.4" diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 23bad082ad..22ea800758 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -1,12 +1,13 @@ pub mod login; pub mod query; pub mod sourcemap; +pub mod tasks; use clap::{Parser, Subcommand}; use query::QueryCommand; use std::path::PathBuf; -use crate::error::CapturedError; +use crate::{commands::tasks::TaskCommand, error::CapturedError, utils::client::SKIP_SSL}; #[derive(Parser)] #[command(version, about, long_about = None)] @@ -25,6 +26,12 @@ pub enum Commands { /// environment variables `POSTHOG_CLI_TOKEN` and `POSTHOG_CLI_ENV_ID` Login, + /// Experimental commands, not quite ready for prime time + Exp { + #[command(subcommand)] + cmd: ExpCommand, + }, + /// Run a SQL query against any data you have in posthog. This is mostly for fun, and subject to change Query { #[command(subcommand)] @@ -94,6 +101,20 @@ pub enum SourcemapCommand { Process(UploadArgs), } +#[derive(Subcommand)] +pub enum ExpCommand { + /// Manage tasks - list, create, update, delete etc + Task { + #[command(subcommand)] + cmd: TaskCommand, + /// Whether to skip SSL verification when talking to the posthog API - only use when using self-signed certificates for + /// self-deployed instances + // TODO - it seems likely we won't support tasks for self hosted, but I'm putting this here in case we do + #[arg(long, default_value = "false")] + skip_ssl_verification: bool, + }, +} + impl Cli { pub fn run() -> Result<(), CapturedError> { let command = Cli::parse(); @@ -115,6 +136,15 @@ impl Cli { } }, Commands::Query { cmd } => query::query_command(command.host, cmd)?, + Commands::Exp { cmd } => match cmd { + ExpCommand::Task { + cmd, + skip_ssl_verification, + } => { + *SKIP_SSL.lock().unwrap() = *skip_ssl_verification; + cmd.run()?; + } + }, } Ok(()) diff --git a/cli/src/commands/sourcemap/upload.rs b/cli/src/commands/sourcemap/upload.rs index 13dab12f96..5f09db0831 100644 --- a/cli/src/commands/sourcemap/upload.rs +++ b/cli/src/commands/sourcemap/upload.rs @@ -12,6 +12,7 @@ use tracing::{info, warn}; use crate::commands::UploadArgs; use crate::utils::auth::load_token; +use crate::utils::client::{get_client, SKIP_SSL}; use crate::utils::posthog::capture_command_invoked; use crate::utils::release::{create_release, CreateReleaseResponse}; use crate::utils::sourcemaps::{read_pairs, ChunkUpload, SourcePair}; @@ -57,6 +58,8 @@ pub fn upload(host: Option, args: UploadArgs) -> Result<()> { batch_size, } = args; + *SKIP_SSL.lock().unwrap() = skip_ssl_verification; + let token = load_token().context("While starting upload command")?; let host = token.get_host(host.as_deref()); @@ -82,7 +85,6 @@ pub fn upload(host: Option, args: UploadArgs) -> Result<()> { Some(content_hash(uploads.iter().map(|upload| &upload.data))), project, version, - skip_ssl_verification, ) .context("While creating release")?; @@ -94,13 +96,7 @@ pub fn upload(host: Option, args: UploadArgs) -> Result<()> { // We could relax this, such that we instead replace the existing release with the new one, // or we could even just allow adding new chunks to an existing release, but for now I'm // leaving it like this... Reviewers, lets chat about the right approach here - upload_chunks( - &base_url, - &token.token, - batch_upload, - release.as_ref(), - skip_ssl_verification, - )?; + upload_chunks(&base_url, &token.token, batch_upload, release.as_ref())?; } if delete_after { @@ -135,11 +131,8 @@ fn upload_chunks( token: &str, uploads: Vec, release: Option<&CreateReleaseResponse>, - skip_ssl_verification: bool, ) -> Result<()> { - let client = reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(skip_ssl_verification) - .build()?; + let client = get_client()?; let release_id = release.map(|r| r.id.to_string()); let chunk_ids = uploads diff --git a/cli/src/commands/tasks/list.rs b/cli/src/commands/tasks/list.rs new file mode 100644 index 0000000000..865c31c8f5 --- /dev/null +++ b/cli/src/commands/tasks/list.rs @@ -0,0 +1,202 @@ +use anyhow::{Context, Result}; +use reqwest::blocking::Client; +use std::collections::VecDeque; + +use super::{TaskListResponse, TaskWorkflow, WorkflowStage}; +use crate::{ + commands::tasks::{ + utils::{fetch_stages, fetch_tasks, fetch_workflows}, + Task, + }, + utils::auth::Token, +}; + +const BUFFER_SIZE: usize = 50; + +pub struct TaskIterator { + client: Client, + token: Token, + buffer: VecDeque, + next_url: Option, +} + +impl TaskIterator { + pub fn new(client: Client, host: String, token: Token, offset: Option) -> Result { + let initial_url = format!("{}/api/environments/{}/tasks/", host, token.env_id); + let mut params = vec![]; + params.push(("limit", BUFFER_SIZE.to_string())); + if let Some(offset) = offset { + params.push(("offset", offset.to_string())); + } + + let response = client + .get(&initial_url) + .query(¶ms) + .header("Authorization", format!("Bearer {}", token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "No response body".to_string()); + anyhow::bail!("Failed to fetch tasks: {} - {}", status, body); + } + + let task_response: TaskListResponse = response + .json() + .context("Failed to parse task list response")?; + + let mut buffer = VecDeque::new(); + buffer.extend(task_response.results); + + Ok(Self { + client, + token, + buffer, + next_url: task_response.next, + }) + } + + fn fetch_next_batch(&mut self) -> Result { + let url = if let Some(next_url) = &self.next_url { + next_url.clone() + } else { + return Ok(false); + }; + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "No response body".to_string()); + anyhow::bail!("Failed to fetch tasks: {} - {}", status, body); + } + + let task_response: TaskListResponse = response + .json() + .context("Failed to parse task list response")?; + + self.buffer.extend(task_response.results); + self.next_url = task_response.next; + + Ok(!self.buffer.is_empty()) + } +} + +impl Iterator for TaskIterator { + type Item = Result; + + fn next(&mut self) -> Option { + if self.buffer.is_empty() { + match self.fetch_next_batch() { + Ok(has_data) => { + if !has_data { + return None; + } + } + Err(e) => return Some(Err(e)), + } + } + + self.buffer.pop_front().map(Ok) + } +} + +pub fn print_task(task: &Task, workflows: &[TaskWorkflow], stages: &[WorkflowStage]) { + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("ID: {}", task.id); + println!("Title: {}", task.title); + + if let Some(desc) = &task.description { + if !desc.is_empty() { + println!("Description: {desc}"); + } + } + + println!("Origin Product: {}", task.origin_product); + println!("Position: {}", task.position); + + if let Some(workflow_id) = &task.workflow { + if let Some(workflow) = workflows.iter().find(|w| &w.id == workflow_id) { + println!( + "Workflow: {}{}", + workflow.name, + if workflow.is_default { + " (default)" + } else { + "" + } + ); + } else { + println!("Workflow: {workflow_id} (unknown)"); + } + } else { + println!("Workflow: None"); + } + + if let Some(stage_id) = &task.current_stage { + if let Some(stage) = stages.iter().find(|s| &s.id == stage_id) { + println!("Stage: {} ({})", stage.name, stage.key); + } else { + println!("Stage: {stage_id} (unknown)"); + } + } else { + println!("Stage: None"); + } + + if let Some(primary_repo) = &task.primary_repository { + if let Some(org) = primary_repo.get("organization").and_then(|v| v.as_str()) { + if let Some(repo) = primary_repo.get("repository").and_then(|v| v.as_str()) { + println!("Repository: {org}/{repo}"); + } + } + } + + if let Some(branch) = &task.github_branch { + println!("GitHub Branch: {branch}"); + } + + if let Some(pr_url) = &task.github_pr_url { + println!("GitHub PR: {pr_url}"); + } + + println!("Created: {}", task.created_at.format("%Y-%m-%d %H:%M UTC")); + println!("Updated: {}", task.updated_at.format("%Y-%m-%d %H:%M UTC")); +} + +pub fn list_tasks(limit: Option<&usize>, offset: Option<&usize>) -> Result<()> { + let token = crate::utils::auth::load_token().context("Failed to load authentication token")?; + let host = token.get_host(None); + let client = Client::new(); + + let workflows: Result> = + fetch_workflows(client.clone(), host.clone(), token.clone())?.collect(); + let workflows = workflows?; + + let stages: Result> = + fetch_stages(client.clone(), host.clone(), token.clone())?.collect(); + let stages = stages?; + + let tasks = fetch_tasks(client, host, token, offset.cloned())?; + + let task_iter: Box>> = if let Some(limit) = limit { + Box::new(tasks.take(*limit)) + } else { + Box::new(tasks) + }; + + for task in task_iter { + print_task(&task?, &workflows, &stages); + } + + Ok(()) +} diff --git a/cli/src/commands/tasks/mod.rs b/cli/src/commands/tasks/mod.rs new file mode 100644 index 0000000000..f8c66d8703 --- /dev/null +++ b/cli/src/commands/tasks/mod.rs @@ -0,0 +1,141 @@ +mod list; +mod progress; +mod update_stage; +mod utils; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use clap::Subcommand; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use self::list::list_tasks; +use self::progress::show_progress; +use self::update_stage::update_stage; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Task { + pub id: Uuid, + pub title: String, + pub description: Option, + pub origin_product: String, + pub position: i32, + pub workflow: Option, + pub current_stage: Option, + pub github_integration: Option, + pub repository_config: Option, + pub repository_list: Option>, + pub primary_repository: Option, + pub github_branch: Option, + pub github_pr_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkflowStage { + pub id: Uuid, + pub workflow: Uuid, + pub name: String, + pub key: String, + pub position: i32, + pub color: String, + pub agent: Option, + pub agent_name: Option, + pub is_manual_only: bool, + pub is_archived: bool, + pub fallback_stage: Option, + pub task_count: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AgentDefinition { + pub id: Uuid, + pub name: String, + pub agent_type: String, + pub description: Option, + pub config: Value, // JSON object + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TaskWorkflow { + pub id: Uuid, + pub name: String, + pub description: Option, + pub color: String, + pub is_default: bool, + pub is_active: bool, + pub version: i32, + pub stages: Vec, + pub task_count: Option, + pub can_delete: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CanDeleteResponse { + pub can_delete: bool, + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TaskListResponse { + pub results: Vec, + pub count: usize, + pub next: Option, + pub previous: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryConfig { + pub integration_id: Option, + pub organization: String, + pub repository: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkflowConfiguration { + pub workflow: TaskWorkflow, + pub stages: Vec, +} + +#[derive(Subcommand)] +pub enum TaskCommand { + /// List all tasks + List { + /// Maximum number of tasks to display + #[arg(long)] + limit: Option, + + /// Page offset for pagination + #[arg(long)] + offset: Option, + }, + + /// View task progress + Progress { + /// Task ID (will prompt for selection if not provided) + task_id: Option, + }, + + /// Update task stage + UpdateStage { + /// Task ID (will prompt for selection if not provided) + task_id: Option, + }, +} + +impl TaskCommand { + pub fn run(&self) -> Result<()> { + match self { + TaskCommand::List { limit, offset } => list_tasks(limit.as_ref(), offset.as_ref()), + TaskCommand::Progress { task_id } => show_progress(task_id.as_ref()), + TaskCommand::UpdateStage { task_id } => update_stage(task_id.as_ref()), + } + } +} diff --git a/cli/src/commands/tasks/progress.rs b/cli/src/commands/tasks/progress.rs new file mode 100644 index 0000000000..968bb036d1 --- /dev/null +++ b/cli/src/commands/tasks/progress.rs @@ -0,0 +1,178 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::commands::tasks::utils::select_task; +use crate::utils::auth::load_token; +use crate::utils::client::get_client; + +#[derive(Debug, Serialize, Deserialize)] +struct TaskProgressResponse { + has_progress: bool, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + current_step: Option, + #[serde(skip_serializing_if = "Option::is_none")] + completed_steps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_steps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + progress_percentage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_log: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + completed_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + workflow_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + workflow_run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +pub fn show_progress(task_id: Option<&Uuid>) -> Result<()> { + // Get the task ID either from the argument or through interactive selection + let task_id = match task_id { + Some(id) => *id, + None => select_task("Select a task to view progress:")?.id, + }; + + // Fetch and display progress + let progress = fetch_progress(&task_id)?; + print_progress(&task_id, &progress); + + Ok(()) +} + +fn fetch_progress(task_id: &Uuid) -> Result { + let token = load_token().context("Failed to load authentication token")?; + let host = token.get_host(None); + let client = get_client()?; + + let url = format!( + "{}/api/environments/{}/tasks/{}/progress/", + host, token.env_id, task_id + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "No response body".to_string()); + anyhow::bail!("Failed to fetch progress: {} - {}", status, body); + } + + let progress: TaskProgressResponse = response + .json() + .context("Failed to parse progress response")?; + + Ok(progress) +} + +fn print_progress(task_id: &Uuid, progress: &TaskProgressResponse) { + println!("\nProgress for Task {task_id}:\n"); + + if !progress.has_progress { + println!( + "{}", + progress + .message + .as_deref() + .unwrap_or("No execution progress found for this task") + ); + return; + } + + if let Some(status) = &progress.status { + println!("Status: {status}"); + } + + if let Some(percentage) = progress.progress_percentage { + let filled = (percentage / 2.0) as usize; + let empty = 50 - filled; + let bar = format!( + "[{}{}] {:.1}%", + "█".repeat(filled), + "░".repeat(empty), + percentage + ); + println!("Progress: {bar}"); + } + + if let Some(current_step) = &progress.current_step { + if !current_step.is_empty() { + println!("Current Step: {current_step}"); + } + } + + if let (Some(completed), Some(total)) = (progress.completed_steps, progress.total_steps) { + println!("Steps: {completed} / {total}"); + } + + if let Some(workflow_id) = &progress.workflow_id { + if !workflow_id.is_empty() { + println!("\nWorkflow ID: {workflow_id}"); + } + } + + if let Some(workflow_run_id) = &progress.workflow_run_id { + if !workflow_run_id.is_empty() { + println!("Workflow Run ID: {workflow_run_id}"); + } + } + + if let Some(output_log) = &progress.output_log { + if !output_log.is_empty() { + println!("\nOutput:"); + println!("{}", "─".repeat(60)); + for line in output_log.lines() { + println!("{line}"); + } + println!("{}", "─".repeat(60)); + } + } + + if let Some(error_message) = &progress.error_message { + if !error_message.is_empty() { + println!("\nError:"); + println!("{}", "─".repeat(60)); + for line in error_message.lines() { + println!("{line}"); + } + println!("{}", "─".repeat(60)); + } + } + + println!("\nTimestamps:"); + if let Some(created_at) = progress.created_at { + println!(" Started: {}", created_at.format("%Y-%m-%d %H:%M:%S UTC")); + } + if let Some(updated_at) = progress.updated_at { + println!( + " Last Updated: {}", + updated_at.format("%Y-%m-%d %H:%M:%S UTC") + ); + } + if let Some(completed_at) = progress.completed_at { + println!( + " Completed: {}", + completed_at.format("%Y-%m-%d %H:%M:%S UTC") + ); + } +} diff --git a/cli/src/commands/tasks/update_stage.rs b/cli/src/commands/tasks/update_stage.rs new file mode 100644 index 0000000000..037bb370ca --- /dev/null +++ b/cli/src/commands/tasks/update_stage.rs @@ -0,0 +1,183 @@ +use anyhow::{Context, Result}; +use inquire::Select; +use reqwest::blocking::Client; +use serde::Serialize; +use uuid::Uuid; + +use super::{Task, TaskWorkflow, WorkflowStage}; +use crate::commands::tasks::utils::select_task; +use crate::utils::auth::load_token; +use crate::utils::client::get_client; + +struct StageChoice(WorkflowStage); + +impl std::fmt::Display for StageChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.0.name, self.0.key) + } +} + +#[derive(Debug, Serialize)] +struct UpdateStageRequest { + current_stage: Uuid, +} + +pub fn update_stage(task_id: Option<&Uuid>) -> Result<()> { + let token = load_token().context("Failed to load authentication token")?; + let host = token.get_host(None); + let client = get_client()?; + + let task = match task_id { + Some(id) => fetch_task(&client, &host, &token, id)?, + None => select_task("Select a task to update stage:")?, + }; + + println!("\nTask: {}", task.title); + + let workflow_id = match task.workflow { + Some(id) => id, + None => { + anyhow::bail!("This task is not associated with any workflow. Cannot update stage."); + } + }; + + let workflow = fetch_workflow(&client, &host, &token, &workflow_id)?; + + if workflow.stages.is_empty() { + anyhow::bail!("The workflow '{}' has no stages defined.", workflow.name); + } + + if let Some(current_stage_id) = &task.current_stage { + if let Some(current_stage) = workflow.stages.iter().find(|s| &s.id == current_stage_id) { + println!( + "Current Stage: {} ({})", + current_stage.name, current_stage.key + ); + } + } else { + println!("Current Stage: None"); + } + + let available_stages: Vec = workflow + .stages + .into_iter() + .filter(|s| !s.is_archived) + .map(StageChoice) + .collect(); + + if available_stages.is_empty() { + anyhow::bail!("No active stages available in the workflow."); + } + + println!("\nAvailable stages:"); + + let selected_stage = Select::new("Select new stage:", available_stages) + .prompt() + .context("Failed to get stage selection")?; + + update_task_stage(&client, &host, &token, &task.id, &selected_stage.0.id)?; + + println!( + "\n✓ Successfully updated task stage to: {} ({})", + selected_stage.0.name, selected_stage.0.key + ); + + Ok(()) +} + +fn fetch_task( + client: &Client, + host: &str, + token: &crate::utils::auth::Token, + task_id: &Uuid, +) -> Result { + let url = format!( + "{}/api/environments/{}/tasks/{}/", + host, token.env_id, task_id + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "No response body".to_string()); + anyhow::bail!("Failed to fetch task: {} - {}", status, body); + } + + let task: Task = response.json().context("Failed to parse task response")?; + + Ok(task) +} + +fn fetch_workflow( + client: &Client, + host: &str, + token: &crate::utils::auth::Token, + workflow_id: &Uuid, +) -> Result { + let url = format!( + "{}/api/environments/{}/task_workflows/{}/", + host, token.env_id, workflow_id + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "No response body".to_string()); + anyhow::bail!("Failed to fetch workflow: {} - {}", status, body); + } + + let workflow: TaskWorkflow = response + .json() + .context("Failed to parse workflow response")?; + + Ok(workflow) +} + +fn update_task_stage( + client: &Client, + host: &str, + token: &crate::utils::auth::Token, + task_id: &Uuid, + stage_id: &Uuid, +) -> Result<()> { + let url = format!( + "{}/api/environments/{}/tasks/{}/update_stage/", + host, token.env_id, task_id + ); + + let request_body = UpdateStageRequest { + current_stage: *stage_id, + }; + + let response = client + .patch(&url) + .header("Authorization", format!("Bearer {}", token.token)) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .unwrap_or_else(|_| "No response body".to_string()); + anyhow::bail!("Failed to update stage: {} - {}", status, body); + } + + Ok(()) +} diff --git a/cli/src/commands/tasks/utils.rs b/cli/src/commands/tasks/utils.rs new file mode 100644 index 0000000000..8f629aecd3 --- /dev/null +++ b/cli/src/commands/tasks/utils.rs @@ -0,0 +1,281 @@ +use anyhow::{Context, Result}; +use inquire::Select; +use reqwest::blocking::Client; +use std::collections::VecDeque; + +use super::{Task, TaskWorkflow, WorkflowStage}; +use crate::commands::tasks::list::TaskIterator; +use crate::utils::auth::Token; + +const PAGE_SIZE: usize = 10; +const BUFFER_SIZE: usize = 50; + +#[allow(clippy::large_enum_variant)] +enum SelectionChoice { + Task(Task), + Next, +} + +impl std::fmt::Display for SelectionChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SelectionChoice::Task(task) => { + write!(f, "{} - {}", task.title, task.origin_product) + } + SelectionChoice::Next => write!(f, "→ Load more tasks..."), + } + } +} + +pub fn select_task(prompt: &str) -> Result { + let token = crate::utils::auth::load_token().context("Failed to load authentication token")?; + let host = token.get_host(None); + let client = crate::utils::client::get_client()?; + + let mut task_iter = fetch_tasks(client, host, token, None)?; + + loop { + let mut choices = Vec::new(); + + // Fetch up to PAGE_SIZE tasks + for _ in 0..PAGE_SIZE { + match task_iter.next() { + Some(Ok(task)) => choices.push(SelectionChoice::Task(task)), + Some(Err(e)) => return Err(e), + None => break, + } + } + + if choices.is_empty() { + anyhow::bail!("No tasks found."); + } + + // If we got exactly PAGE_SIZE items, assume there might be more + if choices.len() == PAGE_SIZE { + choices.push(SelectionChoice::Next); + } + + let selection = Select::new(prompt, choices) + .prompt() + .context("Failed to get task selection")?; + + match selection { + SelectionChoice::Task(task) => return Ok(task), + SelectionChoice::Next => continue, + } + } +} + +pub fn fetch_tasks( + client: Client, + host: String, + token: Token, + offset: Option, +) -> Result { + TaskIterator::new(client, host, token, offset) +} + +#[derive(serde::Deserialize)] +struct WorkflowListResponse { + results: Vec, + next: Option, +} + +pub struct WorkflowIterator { + client: Client, + token: Token, + buffer: VecDeque, + next_url: Option, +} + +impl WorkflowIterator { + fn new(client: Client, host: String, token: Token) -> Result { + let initial_url = format!( + "{}/api/environments/{}/task_workflows/?limit={}", + host, token.env_id, BUFFER_SIZE + ); + + let response = client + .get(&initial_url) + .header("Authorization", format!("Bearer {}", token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + // Return empty iterator on error, don't fail + return Ok(Self { + client, + token, + buffer: VecDeque::new(), + next_url: None, + }); + } + + let workflow_response: WorkflowListResponse = response + .json() + .context("Failed to parse workflow list response")?; + + let mut buffer = VecDeque::new(); + buffer.extend(workflow_response.results); + + Ok(Self { + client, + token, + buffer, + next_url: workflow_response.next, + }) + } + + fn fetch_next_batch(&mut self) -> Result { + let url = if let Some(next_url) = &self.next_url { + next_url.clone() + } else { + return Ok(false); + }; + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + return Ok(false); + } + + let workflow_response: WorkflowListResponse = response + .json() + .context("Failed to parse workflow list response")?; + + self.buffer.extend(workflow_response.results); + self.next_url = workflow_response.next; + + Ok(!self.buffer.is_empty()) + } +} + +impl Iterator for WorkflowIterator { + type Item = Result; + + fn next(&mut self) -> Option { + if self.buffer.is_empty() { + match self.fetch_next_batch() { + Ok(has_data) => { + if !has_data { + return None; + } + } + Err(e) => return Some(Err(e)), + } + } + + self.buffer.pop_front().map(Ok) + } +} + +pub fn fetch_workflows(client: Client, host: String, token: Token) -> Result { + WorkflowIterator::new(client, host, token) +} + +#[derive(serde::Deserialize)] +struct StageListResponse { + results: Vec, + next: Option, +} + +pub struct StageIterator { + client: Client, + token: Token, + buffer: VecDeque, + next_url: Option, +} + +impl StageIterator { + fn new(client: Client, host: String, token: Token) -> Result { + let initial_url = format!( + "{}/api/environments/{}/workflow_stages/?limit={}", + host, token.env_id, BUFFER_SIZE + ); + + let response = client + .get(&initial_url) + .header("Authorization", format!("Bearer {}", token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + return Ok(Self { + client, + token, + buffer: VecDeque::new(), + next_url: None, + }); + } + + let stage_response: StageListResponse = response + .json() + .context("Failed to parse stage list response")?; + + let mut buffer = VecDeque::new(); + buffer.extend(stage_response.results); + + Ok(Self { + client, + token, + buffer, + next_url: stage_response.next, + }) + } + + fn fetch_next_batch(&mut self) -> Result { + let url = if let Some(next_url) = &self.next_url { + next_url.clone() + } else { + return Ok(false); + }; + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.token.token)) + .send() + .context("Failed to send request")?; + + if !response.status().is_success() { + return Ok(false); + } + + let stage_response: StageListResponse = response + .json() + .context("Failed to parse stage list response")?; + + self.buffer.extend(stage_response.results); + self.next_url = stage_response.next; + + Ok(!self.buffer.is_empty()) + } +} + +impl Iterator for StageIterator { + type Item = Result; + + fn next(&mut self) -> Option { + if self.buffer.is_empty() { + match self.fetch_next_batch() { + Ok(has_data) => { + if !has_data { + return None; + } + } + Err(e) => return Some(Err(e)), + } + } + + self.buffer.pop_front().map(Ok) + } +} + +pub fn fetch_stages(client: Client, host: String, token: Token) -> Result { + StageIterator::new(client, host, token) +} diff --git a/cli/src/tui/query.rs b/cli/src/tui/query.rs index ac4f004884..58c6e00d7e 100644 --- a/cli/src/tui/query.rs +++ b/cli/src/tui/query.rs @@ -30,6 +30,7 @@ pub struct QueryTui { state_dirty: bool, } +#[allow(clippy::large_enum_variant)] enum LowerPanelState { TableState(TableState), DebugState(TextArea<'static>), diff --git a/cli/src/utils/client.rs b/cli/src/utils/client.rs new file mode 100644 index 0000000000..42119c4aee --- /dev/null +++ b/cli/src/utils/client.rs @@ -0,0 +1,14 @@ +use std::sync::Mutex; + +use anyhow::Result; +use reqwest::blocking::Client; + +// Olly can have a little global state, as a treat. I could make this a OnceCell, to assert it's written to exactly once, and that'd +// probably make it more correct, or at least more difficult to misuse. Alas, war mode. +pub static SKIP_SSL: Mutex = Mutex::new(false); + +pub fn get_client() -> Result { + Ok(reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(*SKIP_SSL.lock().unwrap()) + .build()?) +} diff --git a/cli/src/utils/mod.rs b/cli/src/utils/mod.rs index e9abb8e49c..bfd268fded 100644 --- a/cli/src/utils/mod.rs +++ b/cli/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod client; pub mod constant; pub mod git; pub mod homedir; diff --git a/cli/src/utils/release.rs b/cli/src/utils/release.rs index 86f3cbb330..69be54c361 100644 --- a/cli/src/utils/release.rs +++ b/cli/src/utils/release.rs @@ -6,6 +6,8 @@ use serde_json::Value; use tracing::{info, warn}; use uuid::Uuid; +use crate::utils::client::get_client; + use super::{auth::Token, git::get_git_info}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -30,7 +32,6 @@ pub fn create_release( hash_id: Option, project: Option, version: Option, - skip_ssl_verification: bool, ) -> Result> { let git_info = get_git_info(dir)?; @@ -64,9 +65,7 @@ pub fn create_release( host, token.env_id ); - let client = reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(skip_ssl_verification) - .build()?; + let client = get_client()?; let response = client .post(&url)