feat(task): list tasks, get progress and set stage via CLI (#38471)

This commit is contained in:
Oliver Browne
2025-09-25 11:03:17 +03:00
committed by GitHub
parent 3e97d0ff02
commit e574d99764
13 changed files with 1079 additions and 54 deletions

75
cli/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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(())

View File

@@ -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<String>, 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<String>, 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<String>, 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<ChunkUpload>,
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

View File

@@ -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<Task>,
next_url: Option<String>,
}
impl TaskIterator {
pub fn new(client: Client, host: String, token: Token, offset: Option<usize>) -> Result<Self> {
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(&params)
.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<bool> {
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<Task>;
fn next(&mut self) -> Option<Self::Item> {
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<Vec<TaskWorkflow>> =
fetch_workflows(client.clone(), host.clone(), token.clone())?.collect();
let workflows = workflows?;
let stages: Result<Vec<WorkflowStage>> =
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<dyn Iterator<Item = Result<Task>>> = 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(())
}

View File

@@ -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<String>,
pub origin_product: String,
pub position: i32,
pub workflow: Option<Uuid>,
pub current_stage: Option<Uuid>,
pub github_integration: Option<i64>,
pub repository_config: Option<Value>,
pub repository_list: Option<Vec<Value>>,
pub primary_repository: Option<Value>,
pub github_branch: Option<String>,
pub github_pr_url: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<Uuid>,
pub agent_name: Option<String>,
pub is_manual_only: bool,
pub is_archived: bool,
pub fallback_stage: Option<Uuid>,
pub task_count: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentDefinition {
pub id: Uuid,
pub name: String,
pub agent_type: String,
pub description: Option<String>,
pub config: Value, // JSON object
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TaskWorkflow {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub color: String,
pub is_default: bool,
pub is_active: bool,
pub version: i32,
pub stages: Vec<WorkflowStage>,
pub task_count: Option<i32>,
pub can_delete: Option<CanDeleteResponse>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CanDeleteResponse {
pub can_delete: bool,
pub reason: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TaskListResponse {
pub results: Vec<Task>,
pub count: usize,
pub next: Option<String>,
pub previous: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryConfig {
pub integration_id: Option<i64>,
pub organization: String,
pub repository: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkflowConfiguration {
pub workflow: TaskWorkflow,
pub stages: Vec<WorkflowStage>,
}
#[derive(Subcommand)]
pub enum TaskCommand {
/// List all tasks
List {
/// Maximum number of tasks to display
#[arg(long)]
limit: Option<usize>,
/// Page offset for pagination
#[arg(long)]
offset: Option<usize>,
},
/// View task progress
Progress {
/// Task ID (will prompt for selection if not provided)
task_id: Option<Uuid>,
},
/// Update task stage
UpdateStage {
/// Task ID (will prompt for selection if not provided)
task_id: Option<Uuid>,
},
}
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()),
}
}
}

View File

@@ -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<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
current_step: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
completed_steps: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
total_steps: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
progress_percentage: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
output_log: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
completed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
workflow_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
workflow_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
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<TaskProgressResponse> {
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")
);
}
}

View File

@@ -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<StageChoice> = 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<Task> {
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<TaskWorkflow> {
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(())
}

View File

@@ -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<Task> {
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<usize>,
) -> Result<TaskIterator> {
TaskIterator::new(client, host, token, offset)
}
#[derive(serde::Deserialize)]
struct WorkflowListResponse {
results: Vec<TaskWorkflow>,
next: Option<String>,
}
pub struct WorkflowIterator {
client: Client,
token: Token,
buffer: VecDeque<TaskWorkflow>,
next_url: Option<String>,
}
impl WorkflowIterator {
fn new(client: Client, host: String, token: Token) -> Result<Self> {
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<bool> {
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<TaskWorkflow>;
fn next(&mut self) -> Option<Self::Item> {
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> {
WorkflowIterator::new(client, host, token)
}
#[derive(serde::Deserialize)]
struct StageListResponse {
results: Vec<WorkflowStage>,
next: Option<String>,
}
pub struct StageIterator {
client: Client,
token: Token,
buffer: VecDeque<WorkflowStage>,
next_url: Option<String>,
}
impl StageIterator {
fn new(client: Client, host: String, token: Token) -> Result<Self> {
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<bool> {
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<WorkflowStage>;
fn next(&mut self) -> Option<Self::Item> {
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> {
StageIterator::new(client, host, token)
}

View File

@@ -30,6 +30,7 @@ pub struct QueryTui {
state_dirty: bool,
}
#[allow(clippy::large_enum_variant)]
enum LowerPanelState {
TableState(TableState),
DebugState(TextArea<'static>),

14
cli/src/utils/client.rs Normal file
View File

@@ -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<bool> = Mutex::new(false);
pub fn get_client() -> Result<Client> {
Ok(reqwest::blocking::Client::builder()
.danger_accept_invalid_certs(*SKIP_SSL.lock().unwrap())
.build()?)
}

View File

@@ -1,4 +1,5 @@
pub mod auth;
pub mod client;
pub mod constant;
pub mod git;
pub mod homedir;

View File

@@ -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<String>,
project: Option<String>,
version: Option<String>,
skip_ssl_verification: bool,
) -> Result<Option<CreateReleaseResponse>> {
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)