Skip to content

Commit

Permalink
add std-chg creation ability
Browse files Browse the repository at this point in the history
  • Loading branch information
bck01215 committed Jun 21, 2024
1 parent 8c262af commit 245b349
Show file tree
Hide file tree
Showing 8 changed files with 263 additions and 78 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
50 changes: 35 additions & 15 deletions ReadMe.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,56 @@
# ElasticNow CLI

This project was inspired by [Preston Gibbs](mailto:[email protected]) 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 <ID>` | The ElasticNow ID (retrieved from ElasticNow instance) [env: ELASTICNOW_ID] |
| `--instance <INSTANCE>` | The ElasticNow instance [env: ELASTICNOW_INSTANCE=] |
| `--sn-instance <SN_INSTANCE>` | The ServiceNow Instance (e.g. libertydev, liberty) [env: SN_INSTANCE=] |
| `--sn-username <SN_USERNAME>` | The ServiceNow Username [env: SN_USERNAME=] |
| `--sn-password <SN_PASSWORD>` | The ServiceNow Password [env: SN_PASSWORD] |
| `-b, --bin <BIN>` | Override default bin for searching (defaults to user's assigned bin) |
| `-h, --help` | Print help |
| Flag | Description |
| ----------------------------- | --------------------------------------------------------------------------- |
| `--id <ID>` | The ElasticNow ID (retrieved from ElasticNow instance) [env: ELASTICNOW_ID] |
| `--instance <INSTANCE>` | The ElasticNow instance [env: ELASTICNOW_INSTANCE=] |
| `--sn-instance <SN_INSTANCE>` | The ServiceNow Instance (e.g. libertydev, liberty) [env: SN_INSTANCE=] |
| `--sn-username <SN_USERNAME>` | The ServiceNow Username [env: SN_USERNAME=] |
| `--sn-password <SN_PASSWORD>` | The ServiceNow Password [env: SN_PASSWORD] |
| `-b, --bin <BIN>` | Override default bin for searching (defaults to user's assigned bin) |
| `-h, --help` | Print help |

Usage: `elasticnow setup [OPTIONS] --id <ID> --instance <INSTANCE> --sn-instance <SN_INSTANCE> --sn-username <SN_USERNAME> --sn-password <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>` | Comment for time tracking |
| `--time-worked <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 <SEARCH>` | Keyword search using ElasticNow (returns all tickets in bin by default) |
| `-b, --bin <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 <COMMENT> --time-worked <TIME_WORKED> --search <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>` | Comment for time tracking |
| `--time-worked <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 <SEARCH>` | Keyword search using ElasticNow (returns all tickets in bin by default) |
| `-b, --bin <BIN>` | Override default bin for searching (defaults to user's assigned bin or override in config.toml) |
| `-s, --search <SEARCH>` | Search for a STD CHG template to create the CHG with |
| `-b, --bin <BIN>` | Override default assignment group when creating the CHG |
| `-t, --template-id <TEMPLATE_ID>` | Use a known template ID to skip the prompt |
| `-h, --help` | Print help |

Usage: `elasticnow timetrack [OPTIONS] --comment <COMMENT> --time-worked <TIME_WORKED> --search <SEARCH>`
Usage: `elasticnow std-chg [OPTIONS]`
35 changes: 34 additions & 1 deletion src/cli/args.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -26,18 +27,33 @@ 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")))
]
time_worked: String,
#[clap(short, long, required_unless_present = "new")]
/// Keyword search using ElasticNow (returns all tickets in bin by default)
search: Option<String>,
#[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<String>,
},

/// 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<String>,
#[clap(short, long, visible_alias = "assignment-group")]
/// Override default assignment group when creating the CHG
bin: Option<String>,

#[clap(short, long, visible_alias = "sys-id")]
/// Use a known template ID to skip the prompt
template_id: Option<String>,
},

#[clap(about = format!("Create a new config file in {}", get_config_dir().display()))]
Setup {
#[clap(long, env = "ELASTICNOW_ID", hide_env_values = true)]
Expand Down Expand Up @@ -93,3 +109,20 @@ pub fn write_short_description() -> String {
pub fn print_completions<G: Generator>(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<SysIdResult>) -> String {
let options: Vec<String> = 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()
}
1 change: 1 addition & 0 deletions src/elasticnow/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod elasticnow;
pub mod servicenow;
pub mod servicenow_structs;
111 changes: 66 additions & 45 deletions src/elasticnow/servicenow.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(rename = "sys_class_name", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
#[serde(rename = "priority", skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(rename = "cat_item", skip_serializing_if = "Option::is_none")]
pub item: Option<String>,
#[serde(rename = "u_sla_type", skip_serializing_if = "Option::is_none")]
pub sla_type: Option<String>,
}

#[derive(Debug, Deserialize)]
struct UserResponse {
pub result: Vec<UserResult>,
}

#[derive(Debug, Deserialize)]
struct UserResult {
#[serde(rename = "u_default_group")]
pub default_group: String,
}
use super::servicenow_structs::CHGCreation;

pub struct ServiceNow {
username: String,
Expand Down Expand Up @@ -96,10 +59,9 @@ impl ServiceNow {
return Err(format!("HTTP Error while querying ServiceNow: {}", resp.status()).into());
}

let user_response = resp.json::<UserResponse>().await?;
let group = user_response.result.first().unwrap();
let user_response = resp.json::<SNResult<Vec<UserGroupResult>>>().await?;

Ok(group.default_group.clone())
Ok(user_response.result[0].default_group.to_owned())
}
pub async fn add_time_to_ticket(
&self,
Expand Down Expand Up @@ -148,11 +110,59 @@ impl ServiceNow {
json_payload.unwrap(),
)
.await?
.json::<SysIdResponse>()
.json::<SNResult<SysIdResult>>()
.await?;

Ok(resp.result.sys_id)
}

// Searches for std chgs in ServiceNow
pub async fn search_std_chg(&self, name: &str) -> Result<Vec<SysIdResult>, Box<dyn Error>> {
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::<SNResult<Vec<SysIdResult>>>(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<String, Box<dyn Error>> {
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::<SNResult<CHGCreation>>(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<String, Box<dyn Error>> {
Expand Down Expand Up @@ -186,6 +196,17 @@ pub fn time_add_to_epoch(time: &str) -> Result<String, Box<dyn Error>> {
Ok(formatted_time)
}

async fn debug_resp_json_deserialize<T: serde::de::DeserializeOwned + std::fmt::Debug>(
resp: reqwest::Response,
) -> Result<T, Box<dyn Error>> {
let text = resp.text().await?;
let json: Result<T, serde_json::Error> = 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::*;
Expand Down
58 changes: 58 additions & 0 deletions src/elasticnow/servicenow_structs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Debug)]
pub struct SNResult<T = ServiceNowResultResponse> {
pub result: T,
}

#[derive(Deserialize, Debug)]
#[serde(untagged)]
pub enum ServiceNowResultResponse {
User(Vec<UserGroupResult>),
SysId(SysIdResult),
SysIds(Vec<SysIdResult>),
CHG(CHGCreation),
}

#[derive(Deserialize, Debug)]
pub struct SysIdResult {
pub sys_id: String,
pub sys_name: Option<String>,
}

#[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<String>,
#[serde(rename = "sys_class_name", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
#[serde(rename = "priority", skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(rename = "cat_item", skip_serializing_if = "Option::is_none")]
pub item: Option<String>,
#[serde(rename = "u_sla_type", skip_serializing_if = "Option::is_none")]
pub sla_type: Option<String>,
}

#[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,
}
Loading

0 comments on commit 245b349

Please sign in to comment.