diff --git a/ReadMe.md b/ReadMe.md index 6ef4982..05afdd8 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -37,6 +37,7 @@ When searching, the CLI will return a list for the user to choose from after que | `--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) | +| `--no-tkt` | Uses timetracking without a ticket | | `-h, --help` | Print help | Usage: `elasticnow timetrack [OPTIONS] --comment --time-worked --search ` @@ -54,3 +55,18 @@ Options: | `-h, --help` | Print help | Usage: `elasticnow std-chg [OPTIONS]` + +### Report + +This gets the user's current time tracking and returns the `--top` results and total time tracking for the range. The duration flags (`--since` and `--until`) default to the current work week. If total is below 32 hours it will return red + +Options: +| Flag | Description | +| --- | --- | +| `-u, --user ` | Override the default user in the report | +| `--since ` | Start date of search (defaults to 2024-06-24) | +| `--until ` | End date of search (defaults to 2024-06-26) | +| `-t, --top ` | Limit the number of cost centers returned in the report. Any extra fields will be grouped into other [default: 10]| +| `-h, --help` | Print help | + +Usage: `elasticnow report [OPTIONS]` diff --git a/src/cli/args.rs b/src/cli/args.rs index bb17bd8..d96d3ee 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -5,7 +5,7 @@ use chrono::{Datelike, Local}; use clap::{Command, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Generator, Shell}; use dialoguer::{theme::ColorfulTheme, Select}; -use std::io; +use std::{collections::HashMap, io}; #[derive(Parser)] #[command(name = "elasticnow", about = "ElasticNow time tracking CLI", version)] pub struct Args { @@ -53,6 +53,10 @@ pub enum Commands { since: Option, #[clap(long, help = format!("End date of search (defaults to {})", get_today()))] until: Option, + + #[clap(short, long, default_value = "10")] + /// Limit the number of cost centers returned in the report. Any extra fields will be grouped into other + top: Option, }, /// Create a std chg using a template @@ -193,3 +197,50 @@ pub fn get_week_start() -> String { now.day() - now.weekday().num_days_from_monday() ) } + +pub fn pretty_print_time_worked(time_worked: HashMap, top: usize) { + let total: i64 = time_worked.values().sum(); + let human_total = seconds_to_pretty(total); + let total_str = ansi_term::Colour::Blue.bold().paint("Total:").to_string(); + let top_ten = group_top_x(time_worked, top); + let mut sorted_top_ten: Vec<_> = top_ten.into_iter().collect(); + sorted_top_ten.sort_by(|a, b| a.1.cmp(&b.1)); + for (k, v) in sorted_top_ten { + println!( + "{}: {}", + ansi_term::Colour::Purple.italic().paint(k), + seconds_to_pretty(v) + ); + } + if total < 3600 * 32 { + println!( + "{}: {}", + total_str, + ansi_term::Colour::Red.bold().paint(human_total) + ); + } else { + println!( + "{}: {}", + total_str, + ansi_term::Colour::Green.bold().paint(human_total) + ); + } +} + +fn seconds_to_pretty(seconds: i64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let seconds = seconds % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) +} + +fn group_top_x(hash_map: HashMap, x: usize) -> HashMap { + let mut sorted_hash_map = hash_map.into_iter().collect::>(); + sorted_hash_map.sort_by(|a, b| b.1.cmp(&a.1)); + let other_total = sorted_hash_map.iter().skip(x).map(|x| x.1).sum(); + let mut ret_map: HashMap = sorted_hash_map.into_iter().take(x).collect(); + if other_total > 0 { + ret_map.insert("Other".to_string(), other_total); + } + ret_map +} diff --git a/src/elasticnow/servicenow.rs b/src/elasticnow/servicenow.rs index 2cde413..80dd29b 100644 --- a/src/elasticnow/servicenow.rs +++ b/src/elasticnow/servicenow.rs @@ -1,5 +1,5 @@ use crate::elasticnow::servicenow_structs::{ - SNResult, SysIdResult, TicketCreation, TimeWorked, UserGroupResult, + CostCenter, SNResult, SysIdResult, TicketCreation, TimeWorked, UserGroupResult, }; use chrono::{TimeZone, Utc}; use regex::Regex; @@ -198,7 +198,7 @@ impl ServiceNow { user: &str, ) -> Result, Box> { let resp = self.get(&format!( - "{}/api/now/table/task_time_worked?sysparm_query=sys_created_by={}^u_created_forBETWEENjavascript:gs.dateGenerate('{}','start')@javascript:gs.dateGenerate('{}','end')", + "{}/api/now/table/task_time_worked?sysparm_fields=task,time_in_seconds,u_category&sysparm_exclude_reference_link=true&sysparm_query=sys_created_by={}^u_created_forBETWEENjavascript:gs.dateGenerate('{}','start')@javascript:gs.dateGenerate('{}','end')", self.instance, user, start, end, )).await?; @@ -211,6 +211,24 @@ impl ServiceNow { .result, ) } + pub async fn get_tasks_cost_centers( + &self, + task_sys_id: &Vec, + ) -> Result, Box> { + let task_sys_ids = task_sys_id.join("^ORtask="); + let resp = self.get( + &format!("{}/api/now/table/task_cost_center?sysparm_query=task={}&sysparm_display_value=all&sysparm_exclude_reference_link=true&sysparm_fields=task,cost_center", self.instance, task_sys_ids), + ).await?; + + if !resp.status().is_success() { + return Err(format!("HTTP Error while querying ServiceNow: {}", resp.status()).into()); + } + Ok( + debug_resp_json_deserialize::>>(resp) + .await? + .result, + ) + } } pub fn time_add_to_epoch(time: &str) -> Result> { diff --git a/src/elasticnow/servicenow_structs.rs b/src/elasticnow/servicenow_structs.rs index ffff31d..2ff0206 100644 --- a/src/elasticnow/servicenow_structs.rs +++ b/src/elasticnow/servicenow_structs.rs @@ -58,6 +58,11 @@ pub struct DisplayAndValue { pub display_value: String, pub value: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct DisplayAndLink { + pub display_value: String, + pub link: String, +} #[derive(Debug, Serialize, Deserialize)] pub struct LinkAndValue { @@ -86,14 +91,14 @@ impl LinkAndValue { #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum TimeWorkedTask { - LinkAndValue(LinkAndValue), + DisplayAndValue(DisplayAndValue), EmptyString(String), } #[derive(Debug, Serialize, Deserialize)] pub struct TimeWorked { pub time_in_seconds: String, - pub task: TimeWorkedTask, + pub task: String, #[serde(rename = "u_category")] pub category: String, } @@ -112,4 +117,5 @@ impl TimeWorked { #[derive(Debug, Serialize, Deserialize)] pub struct CostCenter { pub cost_center: DisplayAndValue, + pub task: DisplayAndValue, } diff --git a/src/main.rs b/src/main.rs index 64f967a..bcba5f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,8 @@ use ansi_term::Colour; use elasticnow::cli::{self, args, config}; use elasticnow::elasticnow::elasticnow::{ElasticNow, SearchResult}; use elasticnow::elasticnow::servicenow::ServiceNow; -use elasticnow::elasticnow::servicenow_structs::TimeWorkedTask; +use std::collections::HashMap; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; - #[tokio::main] async fn main() { tracing_subscriber::registry() @@ -42,8 +41,13 @@ async fn main() { run_setup(id, instance, sn_instance, sn_username, sn_password, bin).await; } - Some(cli::args::Commands::Report { user, since, until }) => { - run_report(user, since, until).await; + Some(cli::args::Commands::Report { + user, + since, + until, + top, + }) => { + run_report(user, since, until, top).await; } _ => { std::process::exit(1); @@ -125,7 +129,12 @@ async fn run_timetrack( std::process::exit(0); } -async fn run_report(user: Option, since: Option, until: Option) { +async fn run_report( + user: Option, + since: Option, + until: Option, + top: Option, +) { let (config, sn_client) = check_config(); let user = user.unwrap_or(config.sn_username.clone()); let since = since.unwrap_or(args::get_week_start()); @@ -134,32 +143,45 @@ async fn run_report(user: Option, since: Option, until: Option = HashMap::new(); let tasks = tasks.unwrap(); + let mut tasks_ids: HashMap = HashMap::new(); for time_work in tasks { - match time_work.task { - TimeWorkedTask::LinkAndValue(link_and_value) => { - println!( - "Time worked seconds: {} {}", - time_work.time_in_seconds, link_and_value.link - ); + match time_work.task.as_ref() { + "" => { + let time_in_seconds: i64 = time_work.time_in_seconds.parse().unwrap_or_default(); + *task_cat_time + .entry(time_work.get_nice_name_category()) + .or_insert(0) += time_in_seconds; } - TimeWorkedTask::EmptyString(_) => { - println!( - "Time worked seconds: {} {}", - time_work.time_in_seconds, - time_work.get_nice_name_category() - ); + _ => { + let time_in_seconds: i64 = time_work.time_in_seconds.parse().unwrap_or_default(); + *tasks_ids.entry(time_work.task).or_insert(0) += time_in_seconds; } } } + let keys = tasks_ids.keys().cloned().collect::>(); + let cost_centers = sn_client.get_tasks_cost_centers(&keys).await; + if cost_centers.is_err() { + tracing::error!("Unable to get cost centers: {:?}", cost_centers.err()); + std::process::exit(1); + } + let cost_centers = cost_centers.unwrap(); + for cost_center in cost_centers { + let time = tasks_ids.get(&cost_center.task.value).unwrap_or(&0); + *task_cat_time + .entry(cost_center.cost_center.display_value) + .or_insert(0) += time; + } + args::pretty_print_time_worked(task_cat_time, top.unwrap_or(10)); std::process::exit(0); } async fn run_setup(