diff --git a/Cargo.lock b/Cargo.lock index 8f1c0d0..120a736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,7 +302,7 @@ dependencies = [ [[package]] name = "elasticnow" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ansi_term", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0b34993..b517b43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "elasticnow" -version = "0.1.0" +version = "0.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/ReadMe.md b/ReadMe.md index a3501da..bb356ba 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,36 +1,56 @@ # ElasticNow CLI + This project was inspired by [Preston Gibbs](mailto:pgibbs1@liberty.edu) and his hate for time tracking. ## Usage + ### Autocomplete + `elasticnow --generate [possible values: bash, elvish, fish, powershell, zsh]` outputs the autocompletion that can be pushed to proper file for autocomplete. + ### Authentication + To initialize the repo you will need to run `elasticnow setup`. This will generate a config.toml. The location is dependent on the operating system but can be found at the top of the help output. -| Flag | Description | -| --- | --- | -| `--id ` | The ElasticNow ID (retrieved from ElasticNow instance) [env: ELASTICNOW_ID] | -| `--instance ` | The ElasticNow instance [env: ELASTICNOW_INSTANCE=] | -| `--sn-instance ` | The ServiceNow Instance (e.g. libertydev, liberty) [env: SN_INSTANCE=] | -| `--sn-username ` | The ServiceNow Username [env: SN_USERNAME=] | -| `--sn-password ` | The ServiceNow Password [env: SN_PASSWORD] | -| `-b, --bin ` | Override default bin for searching (defaults to user's assigned bin) | -| `-h, --help` | Print help | +| Flag | Description | +| ----------------------------- | --------------------------------------------------------------------------- | +| `--id ` | The ElasticNow ID (retrieved from ElasticNow instance) [env: ELASTICNOW_ID] | +| `--instance ` | The ElasticNow instance [env: ELASTICNOW_INSTANCE=] | +| `--sn-instance ` | The ServiceNow Instance (e.g. libertydev, liberty) [env: SN_INSTANCE=] | +| `--sn-username ` | The ServiceNow Username [env: SN_USERNAME=] | +| `--sn-password ` | The ServiceNow Password [env: SN_PASSWORD] | +| `-b, --bin ` | Override default bin for searching (defaults to user's assigned bin) | +| `-h, --help` | Print help | Usage: `elasticnow setup [OPTIONS] --id --instance --sn-instance --sn-username --sn-password ` ### Time Tracking + Time tracking is dependent on the initial setup. You can use the search flag to search for an existing ticket in your bin (override with --bin), or create a new ticket. When searching, the CLI will return a list for the user to choose from after querying all active tickets in the bin matching the search key words. +| Flag | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------- | +| `-n, --new` | Creates a new ticket instead of updating an existing one ( cannot be used with `--search` ) | +| `-c, --comment ` | Comment for time tracking | +| `--time-worked ` | Add time in the format of 1h1m where 1 can be replaced with any number (hours must be less than 24) | +| `-s, --search ` | Keyword search using ElasticNow (returns all tickets in bin by default) | +| `-b, --bin ` | Override default bin for searching (defaults to user's assigned bin or override in config.toml) | +| `-h, --help` | Print help | + +Usage: `elasticnow timetrack [OPTIONS] --comment --time-worked --search ` + +### Standard Changes + +This just uses the ServiceNow API to query STD CHG templates and prompt the user for correct one. Alternatively, provide the sys_id of the template to avoid being prompted. + +Options: | Flag | Description | | --- | --- | -| `-n, --new` | Creates a new ticket instead of updating an existing one ( cannot be used with `--search` ) | -| `-c, --comment ` | Comment for time tracking | -| `--time-worked ` | Add time in the format of 1h1m where 1 can be replaced with any number (hours must be less than 24) | -| `-s, --search ` | Keyword search using ElasticNow (returns all tickets in bin by default) | -| `-b, --bin ` | Override default bin for searching (defaults to user's assigned bin or override in config.toml) | +| `-s, --search ` | Search for a STD CHG template to create the CHG with | +| `-b, --bin ` | Override default assignment group when creating the CHG | +| `-t, --template-id ` | Use a known template ID to skip the prompt | | `-h, --help` | Print help | -Usage: `elasticnow timetrack [OPTIONS] --comment --time-worked --search ` +Usage: `elasticnow std-chg [OPTIONS]` diff --git a/src/cli/args.rs b/src/cli/args.rs index f6a7128..dd92d19 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,4 +1,5 @@ use crate::cli::config::get_config_dir; +use crate::elasticnow::servicenow_structs::SysIdResult; use ansi_term::Colour; use clap::{Command, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Generator, Shell}; @@ -26,6 +27,7 @@ pub enum Commands { /// Comment for time tracking comment: String, #[clap( + short, long, help = format!("Add time in the format of {} where 1 can be replaced with any number (hours must be less than 24)", Colour::Green.bold().paint("1h1m"))) ] @@ -33,11 +35,25 @@ pub enum Commands { #[clap(short, long, required_unless_present = "new")] /// Keyword search using ElasticNow (returns all tickets in bin by default) search: Option, - #[clap(short, long)] + #[clap(short, long, visible_alias = "assignment-group")] /// Override default bin for searching (defaults to user's assigned bin or override in config.toml) bin: Option, }, + /// Create a std chg using a template + StdChg { + #[clap(short, long, required_unless_present = "template_id")] + /// Search for a STD CHG template to create the CHG with + search: Option, + #[clap(short, long, visible_alias = "assignment-group")] + /// Override default assignment group when creating the CHG + bin: Option, + + #[clap(short, long, visible_alias = "sys-id")] + /// Use a known template ID to skip the prompt + template_id: Option, + }, + #[clap(about = format!("Create a new config file in {}", get_config_dir().display()))] Setup { #[clap(long, env = "ELASTICNOW_ID", hide_env_values = true)] @@ -93,3 +109,20 @@ pub fn write_short_description() -> String { pub fn print_completions(gen: G, cmd: &mut Command) { generate(gen, cmd, cmd.get_name().to_string(), &mut io::stdout()); } + +// Takes in a list of CHG templates with names and sys_id and returns the sys_id of the chosen template +pub fn choose_chg_template(chg_templates: Vec) -> String { + let options: Vec = chg_templates + .iter() + .map(|t| format!("{}", t.sys_name.as_ref().unwrap())) + .collect(); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Please choose a CHG Template:") + .default(0) + .items(&options) + .interact() + .unwrap(); + + chg_templates[selection].sys_id.clone() +} diff --git a/src/elasticnow/mod.rs b/src/elasticnow/mod.rs index df52947..fb92cd2 100644 --- a/src/elasticnow/mod.rs +++ b/src/elasticnow/mod.rs @@ -1,2 +1,3 @@ pub mod elasticnow; pub mod servicenow; +pub mod servicenow_structs; diff --git a/src/elasticnow/servicenow.rs b/src/elasticnow/servicenow.rs index 728ce89..94caef9 100644 --- a/src/elasticnow/servicenow.rs +++ b/src/elasticnow/servicenow.rs @@ -1,50 +1,13 @@ +use crate::elasticnow::servicenow_structs::{ + SNResult, SysIdResult, TicketCreation, UserGroupResult, +}; use chrono::{TimeZone, Utc}; use regex::Regex; use reqwest::Client; -use serde::{Deserialize, Serialize}; use std::error::Error; use tracing::debug; -#[derive(Deserialize)] -struct SysIdResponse { - result: SysIdResult, -} - -#[derive(Deserialize)] -struct SysIdResult { - sys_id: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct TicketCreation { - #[serde(rename = "assignment_group")] - pub assignment_group: String, - #[serde(rename = "short_description")] - pub short_description: String, - #[serde(rename = "description")] - pub description: String, - #[serde(rename = "cmdb_ci", skip_serializing_if = "Option::is_none")] - pub configuration_item: Option, - #[serde(rename = "sys_class_name", skip_serializing_if = "Option::is_none")] - pub type_: Option, - #[serde(rename = "priority", skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(rename = "cat_item", skip_serializing_if = "Option::is_none")] - pub item: Option, - #[serde(rename = "u_sla_type", skip_serializing_if = "Option::is_none")] - pub sla_type: Option, -} - -#[derive(Debug, Deserialize)] -struct UserResponse { - pub result: Vec, -} - -#[derive(Debug, Deserialize)] -struct UserResult { - #[serde(rename = "u_default_group")] - pub default_group: String, -} +use super::servicenow_structs::CHGCreation; pub struct ServiceNow { username: String, @@ -96,10 +59,9 @@ impl ServiceNow { return Err(format!("HTTP Error while querying ServiceNow: {}", resp.status()).into()); } - let user_response = resp.json::().await?; - let group = user_response.result.first().unwrap(); + let user_response = resp.json::>>().await?; - Ok(group.default_group.clone()) + Ok(user_response.result[0].default_group.to_owned()) } pub async fn add_time_to_ticket( &self, @@ -148,11 +110,59 @@ impl ServiceNow { json_payload.unwrap(), ) .await? - .json::() + .json::>() .await?; Ok(resp.result.sys_id) } + + // Searches for std chgs in ServiceNow + pub async fn search_std_chg(&self, name: &str) -> Result, Box> { + let resp = self.get(&format!( + "{}/api/now/table/std_change_record_producer?sysparm_query=sys_nameLIKE{}^active=true&sysparm_fields=sys_id,sys_name", + self.instance, name + )).await?; + if !resp.status().is_success() { + return Err(format!("HTTP Error while querying ServiceNow: {}", resp.status()).into()); + } + let result = debug_resp_json_deserialize::>>(resp).await; + if result.is_err() { + let error_msg = format!("JSON error: {}", result.unwrap_err()); + tracing::error!("{}", error_msg); + return Err(error_msg.into()); + } + Ok(result.unwrap().result) + } + + // Returns the sys_id of created CHG or errors + pub async fn create_std_chg_from_template( + &self, + template_sys_id: &str, + assignment_group: &str, + ) -> Result> { + let post_body = serde_json::json!({ + "assignment_group": assignment_group + }); + let resp = self + .post_json( + &format!( + "{}/api/sn_chg_rest/change/standard/{}", + self.instance, template_sys_id + ), + post_body, + ) + .await?; + if !resp.status().is_success() { + return Err(format!("HTTP Error while querying ServiceNow: {}", resp.status()).into()); + } + let result = debug_resp_json_deserialize::>(resp).await; + if result.is_err() { + let error_msg = format!("JSON error: {}", result.unwrap_err()); + tracing::error!("{}", error_msg); + return Err(error_msg.into()); + } + Ok(result.unwrap().result.sys_id.value) + } } pub fn time_add_to_epoch(time: &str) -> Result> { @@ -186,6 +196,17 @@ pub fn time_add_to_epoch(time: &str) -> Result> { Ok(formatted_time) } +async fn debug_resp_json_deserialize( + resp: reqwest::Response, +) -> Result> { + let text = resp.text().await?; + let json: Result = serde_json::from_str(&text); + if json.is_err() { + return Err(format!("JSON error: {} \n{}", json.unwrap_err(), text).into()); + } + Ok(json.unwrap()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/elasticnow/servicenow_structs.rs b/src/elasticnow/servicenow_structs.rs new file mode 100644 index 0000000..31b3348 --- /dev/null +++ b/src/elasticnow/servicenow_structs.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Debug)] +pub struct SNResult { + pub result: T, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum ServiceNowResultResponse { + User(Vec), + SysId(SysIdResult), + SysIds(Vec), + CHG(CHGCreation), +} + +#[derive(Deserialize, Debug)] +pub struct SysIdResult { + pub sys_id: String, + pub sys_name: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct UserGroupResult { + #[serde(rename = "u_default_group")] + pub default_group: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TicketCreation { + #[serde(rename = "assignment_group")] + pub assignment_group: String, + #[serde(rename = "short_description")] + pub short_description: String, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "cmdb_ci", skip_serializing_if = "Option::is_none")] + pub configuration_item: Option, + #[serde(rename = "sys_class_name", skip_serializing_if = "Option::is_none")] + pub type_: Option, + #[serde(rename = "priority", skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(rename = "cat_item", skip_serializing_if = "Option::is_none")] + pub item: Option, + #[serde(rename = "u_sla_type", skip_serializing_if = "Option::is_none")] + pub sla_type: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CHGCreation { + pub sys_id: DisplayAndValue, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DisplayAndValue { + pub display_value: String, + pub value: String, +} diff --git a/src/main.rs b/src/main.rs index d66c797..a44f4ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,13 @@ async fn main() { }) => { run_setup(id, instance, sn_instance, sn_username, sn_password, bin).await; } + Some(cli::args::Commands::StdChg { + search, + bin, + template_id, + }) => { + run_stdchg(search.unwrap_or_default(), bin, template_id).await; + } _ => { std::process::exit(1); } @@ -44,26 +51,12 @@ async fn run_timetrack( search: Option, bin: Option, ) { - let config = config::Config::from_toml_file(); - if config.is_err() { - tracing::error!( - "Unable to load config file. Please run {} and try again.", - Colour::Green.bold().paint("elasticnow setup") - ); - std::process::exit(2); - } - let config = config.unwrap(); - + let (config, sn_client) = check_config(); tracing::debug!("New: {:?}", new); tracing::debug!("Comment: {:?}", comment); tracing::debug!("Time Worked: {:?}", time_worked); tracing::debug!("Search: {:?}", search); tracing::debug!("Bin: {:?}", bin); - let sn_client = ServiceNow::new( - &config.sn_username, - &config.sn_password, - &config.sn_instance, - ); let sys_id: String; let tkt_bin = bin.unwrap_or(config.bin.clone()); @@ -152,6 +145,47 @@ async fn run_setup( } } +async fn run_stdchg(search: String, bin: Option, template_id: Option) { + let (config, sn_client) = check_config(); + tracing::debug!("Search: {:?}", search); + tracing::debug!("Bin: {:?}", bin); + tracing::debug!("Template ID: {:?}", template_id); + let bin = bin.unwrap_or(config.bin.clone()); + let template_sys_id: String; + if template_id.is_none() { + let std_changes_resp = sn_client.search_std_chg(&search).await; + if std_changes_resp.is_err() { + tracing::error!("Unable to search std chgs: {:?}", std_changes_resp.err()); + std::process::exit(2); + } + let std_changes = std_changes_resp.unwrap(); + if std_changes.is_empty() { + tracing::error!("No std chgs found for search: {}", search); + std::process::exit(1); + } + template_sys_id = cli::args::choose_chg_template(std_changes); + } else { + template_sys_id = template_id.unwrap(); + } + tracing::debug!("Selected chg_id: {}", template_sys_id); + let resp = sn_client + .create_std_chg_from_template(&template_sys_id, &bin) + .await; + if resp.is_err() { + tracing::error!("Unable to create std chg: {:?}", resp.err()); + std::process::exit(2); + } + let sys_id = resp.unwrap(); + tracing::info!("Created std chg: {}", sys_id); + let ticket_url = ansi_term::Colour::Blue.paint(format!( + "https://{}.service-now.com/change_request.do?sys_id={}", + &config.sn_instance, sys_id + )); + println!("Link to CHG: {}", ticket_url); + + std::process::exit(0); +} + //Returns the sys_id of new ticket async fn new_ticket(sn_client: &ServiceNow, config: &config::Config) -> String { let desc = cli::args::write_short_description(); @@ -198,3 +232,21 @@ fn get_search_result_from_input(input: &str, result: Vec) -> Optio } None } + +fn check_config() -> (config::Config, ServiceNow) { + let config = config::Config::from_toml_file(); + if config.is_err() { + tracing::error!( + "Unable to load config file. Please run {} and try again.", + Colour::Green.bold().paint("elasticnow setup") + ); + std::process::exit(2); + } + let config = config.unwrap(); + let sn_client = ServiceNow::new( + &config.sn_username, + &config.sn_password, + &config.sn_instance, + ); + (config, sn_client) +}