From fc23ddd374b666ca81892a06e0a1bbbb6dec897f Mon Sep 17 00:00:00 2001 From: Yann Amsellem Date: Mon, 1 Dec 2025 14:47:04 +0100 Subject: [PATCH] feat(teams): implement team list and select cmd --- Cargo.lock | 161 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/commands/mod.rs | 2 + src/commands/team/mod.rs | 161 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 8 ++ 5 files changed, 333 insertions(+) create mode 100644 src/commands/team/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3e94f6d..4857055 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,7 @@ dependencies = [ "clap", "futures-util", "indicatif", + "inquire", "jsonwebtoken", "open", "reqwest", @@ -429,6 +430,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -469,6 +479,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -488,6 +525,27 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -510,12 +568,27 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -672,6 +745,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1051,6 +1133,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" +dependencies = [ + "bitflags 2.10.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1162,6 +1258,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1228,6 +1330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1769,6 +1872,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -1943,6 +2067,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -2123,6 +2256,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" @@ -2292,6 +2431,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index ae081c8..46f15dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" jsonwebtoken = {version = "10.2.0", features = ["aws_lc_rs"] } thiserror = "2.0.17" +inquire = "0.9.1" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7b4eecd..fd498a0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,9 +1,11 @@ pub mod pipeline; pub mod project; pub mod system; +pub mod team; pub mod user; pub use pipeline::{PipelineAction, handle_pipeline_command}; pub use project::{ProjectAction, handle_project_command}; pub use system::SystemAction; +pub use team::TeamAction; pub use user::UserAction; diff --git a/src/commands/team/mod.rs b/src/commands/team/mod.rs new file mode 100644 index 0000000..c47b927 --- /dev/null +++ b/src/commands/team/mod.rs @@ -0,0 +1,161 @@ +use std::{error::Error, fmt::Display, fs, path::PathBuf}; + +use clap::Subcommand; +use inquire::Select; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use crate::utils::{AppConfig, AuthTokens, ensure_valid_tokens}; + +#[derive(Subcommand, Debug)] +pub enum TeamAction { + List, + Select, +} + +/// Agnostic Team entity +#[derive(Serialize, Deserialize)] +pub struct Team { + id: u8, + name: String, + slug: String, + #[serde(rename = "type")] + variant: String, +} + +impl Display for Team { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +/// List teams JSON Response body +#[derive(Serialize, Deserialize)] +struct ListTeamsResponse { + teams: Vec, +} + +impl TeamAction { + pub async fn handle(self, config: &AppConfig) { + let client = Client::new(); + let auth_tokens = match ensure_valid_tokens(config, &client).await { + Ok(tokens) => tokens, + Err(e) => { + if config.verbose { + eprintln!("{}", e) + } + println!("Authentication required. Please run `user login` first."); + return; + } + }; + + match self { + Self::List => self + .handle_list(&client, &auth_tokens, config) + .await + .expect("Unable to handle list command"), + Self::Select => self + .handle_select(&client, &auth_tokens, config) + .await + .expect("Unable to handle select command"), + } + } + + async fn handle_list( + self, + client: &Client, + tokens: &AuthTokens, + config: &AppConfig, + ) -> Result<(), Box> { + let response = client + .get("https://app.agnostic.tech/api/teams") + .bearer_auth(tokens.id_token()) + .send() + .await?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + println!("Authentication failed. Please try to log in again."); + return Ok(()); + } + + let body: ListTeamsResponse = response.json().await?; + + println!("Teams"); + println!("============="); + println!(); + + let current = get_current_team(config); + for team in body.teams { + if current.as_ref().map(|p| p.id) == Some(team.id) { + println!("> {} (current)", team); + } else { + println!("> {}", team); + } + } + println!(); + + Ok(()) + } + + async fn handle_select( + self, + client: &Client, + tokens: &AuthTokens, + config: &AppConfig, + ) -> Result<(), Box> { + let response = client + .get("https://app.agnostic.tech/api/teams") + .bearer_auth(tokens.id_token()) + .send() + .await?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + println!("Authentication failed. Please try to log in again."); + return Ok(()); + } + + let previous = get_current_team(config); + + let body: ListTeamsResponse = response.json().await?; + + let options: Vec = body + .teams + .iter() + .map(|t| { + if previous.as_ref().map(|p| p.id) == Some(t.id) { + format!("{} (current)", t) + } else { + t.name.clone() + } + }) + .collect(); + + let selected_option = Select::new("Select a team:", options.clone()) + .with_help_message("Use ↑↓, type to filter") + .prompt()?; + + let index = options.iter().position(|o| *o == selected_option).unwrap(); + let selected = body.teams.get(index).unwrap(); + + println!(); + + fs::write( + get_team_json_path(config), + &serde_json::to_string_pretty(selected)?, + )?; + + println!("Choice saved."); + + Ok(()) + } +} + +pub fn get_current_team(config: &AppConfig) -> Option { + fs::read_to_string(get_team_json_path(config)) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) +} + +fn get_team_json_path(config: &AppConfig) -> PathBuf { + config.agnostic_dir.join("user/team.json") +} diff --git a/src/main.rs b/src/main.rs index 08a8091..6256dfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,8 @@ use commands::{ }; use utils::app::{cleanup_app, initialize_app}; +use crate::commands::TeamAction; + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { @@ -36,6 +38,11 @@ enum Commands { action: SystemAction, }, + Team { + #[command(subcommand)] + action: TeamAction, + }, + User { #[command(subcommand)] action: UserAction, @@ -68,6 +75,7 @@ async fn main() { Commands::Project { action } => handle_project_command(action).await, Commands::Pipeline { action } => handle_pipeline_command(action).await, Commands::System { action } => action.handle(&config).await, + Commands::Team { action } => action.handle(&config).await, Commands::User { action } => action.handle(&config).await, };