From 966e34e9e741023e2c9367a972754a2c8245f91e Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Thu, 18 Jul 2024 10:23:47 +0200 Subject: [PATCH 1/6] cli: refactor data-access layer: add filter and views module The `filtering` module provides a more centralized way to filter the data. This will also make further extensions simpler. The `views` module provides specific views required for the binary. --- src/filtering.rs | 151 ++++++++++++++++++++++++++++++++++++++++++ src/gitlab_api.rs | 74 ++++++++++++++------- src/main.rs | 163 ++++++++++++---------------------------------- src/views.rs | 76 +++++++++++++++++++++ 4 files changed, 321 insertions(+), 143 deletions(-) create mode 100644 src/filtering.rs create mode 100644 src/views.rs diff --git a/src/filtering.rs b/src/filtering.rs new file mode 100644 index 0000000..a545f5e --- /dev/null +++ b/src/filtering.rs @@ -0,0 +1,151 @@ +/* +MIT License + +Copyright (c) 2024 Philipp Schuster + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +//! Convenience abstractions to filter the [`ResponseNode`]s of the [`Response`] +//! from the GitLab API. + +use crate::gitlab_api::types::{Response, ResponseNode}; +use chrono::{Datelike, NaiveDate}; + +/// Filters the `timelogs` [`Response`] and only emits [`ResponseNode`]s +/// matching all filters. +/// +/// # Parameters +/// - `data`: The entire [`Response`] +/// - `time_filter`: The optional [`TimeFilter`] to apply +/// - `group_filter`: The optional [`GroupFilter`] to apply +/// - `epic_filter`: The optional [`EpicFilter`] to apply +pub fn filter_timelogs<'a>( + response: &'a Response, + time_filter: Option, + group_filter: Option>, + epic_filter: Option>, +) -> impl Iterator { + response + .data + .timelogs + .nodes + .iter() + .filter_nodes( + time_filter + .map(FilterKind::Time) + .unwrap_or(FilterKind::Noop), + ) + .filter_nodes( + group_filter + .map(FilterKind::Group) + .unwrap_or(FilterKind::Noop), + ) + .filter_nodes( + epic_filter + .map(FilterKind::Epic) + .unwrap_or(FilterKind::Noop), + ) +} + +trait IteratorExt<'a>: Iterator { + fn filter_nodes(self, filter: FilterKind<'a>) -> NodeFilter<'a, Self> + where + Self: Sized, + { + NodeFilter::new(self, filter) + } +} + +impl<'a, I: Iterator> IteratorExt<'a> for I {} + +struct NodeFilter<'a, I: Iterator> { + it: I, + typ: FilterKind<'a>, +} + +impl<'a, I: Iterator> NodeFilter<'a, I> { + const fn new(it: I, filter: FilterKind<'a>) -> Self { + NodeFilter { it, typ: filter } + } +} + +impl<'a, I: Iterator> Iterator for NodeFilter<'_, I> { + type Item = I::Item; + + fn next(&mut self) -> Option { + let item = self.it.next()?; + match self.typ { + FilterKind::Time(TimeFilter::WithinInclusive(before_date, after_date)) => { + (item.datetime() >= after_date && item.datetime() <= before_date).then_some(item) + } + FilterKind::Time(TimeFilter::AfterInclusive(date)) => { + (item.datetime() >= date).then_some(item) + } + FilterKind::Time(TimeFilter::BeforeInclusive(date)) => { + (item.datetime() <= date).then_some(item) + } + FilterKind::Time(TimeFilter::Week { year, week }) => ({ + let isoweek = item.datetime().iso_week(); + isoweek.year() == year && isoweek.week() == week + }) + .then_some(item), + FilterKind::Group(GroupFilter::HasGroup(group)) => { + item.has_group(group).then_some(item) + } + FilterKind::Epic(EpicFilter::HasEpic(epic)) => item.has_epic(epic).then_some(item), + FilterKind::Group(GroupFilter::HasNoGroup) => { + item.group_name().is_none().then_some(item) + } + FilterKind::Epic(EpicFilter::HasNoEpic) => item.epic_name().is_none().then_some(item), + FilterKind::Noop => Some(item), + } + } +} + +enum FilterKind<'a> { + Time(TimeFilter), + Group(GroupFilter<'a>), + Epic(EpicFilter<'a>), + Noop, +} + +#[allow(unused)] +pub enum EpicFilter<'a> { + HasEpic(&'a str), + HasNoEpic, +} + +#[allow(unused)] +pub enum GroupFilter<'a> { + HasGroup(&'a str), + HasNoGroup, +} + +#[allow(unused)] +pub enum TimeFilter { + AfterInclusive(NaiveDate), + BeforeInclusive(NaiveDate), + WithinInclusive(NaiveDate, NaiveDate), + Week { + year: i32, + /// ISO week id. + week: u32, + }, +} diff --git a/src/gitlab_api.rs b/src/gitlab_api.rs index 77a6544..25c92c0 100644 --- a/src/gitlab_api.rs +++ b/src/gitlab_api.rs @@ -23,11 +23,16 @@ SOFTWARE. */ #[allow(non_snake_case)] pub mod types { - + use chrono::{DateTime, Local, NaiveDate}; use serde::Deserialize; use std::time::Duration; - #[derive(Deserialize, Debug)] + #[derive(Clone, Deserialize, Debug)] + pub struct Epic { + pub title: String, + } + + #[derive(Clone, Deserialize, Debug)] pub struct Issue { pub title: String, /// Full http link to issue. @@ -35,7 +40,17 @@ pub mod types { pub epic: Option, } - #[derive(Deserialize, Debug)] + #[derive(Clone, Deserialize, Debug)] + pub struct Group { + pub fullName: String, + } + + #[derive(Clone, Deserialize, Debug)] + pub struct Project { + pub group: Option, + } + + #[derive(Clone, Deserialize, Debug)] pub struct ResponseNode { pub spentAt: String, /// For some totally weird reason, GitLab allows negative times. @@ -53,42 +68,57 @@ pub mod types { let dur = Duration::from_secs(self.timeSpent.unsigned_abs()); (self.timeSpent.is_positive(), dur) } + + pub fn group_name(&self) -> Option<&str> { + self.project.group.as_ref().map(|g| g.fullName.as_str()) + } + + pub fn epic_name(&self) -> Option<&str> { + self.issue.epic.as_ref().map(|e| e.title.as_str()) + } + + pub fn has_group(&self, name: &str) -> bool { + self.group_name() + .map(|group| group == name) + .unwrap_or(false) + } + + pub fn has_epic(&self, name: &str) -> bool { + self.epic_name().map(|epic| epic == name).unwrap_or(false) + } + + /// Parses the UTC timestring coming from GitLab in the local timezone of + /// the user. This is necessary so that entries accounted to a Monday on + /// `00:00` in CEST are not displayed as Sunday. The value is returned + /// as [`NaiveDate`] but adjusted to the local time. + pub fn datetime(&self) -> NaiveDate { + let date = DateTime::parse_from_rfc3339(&self.spentAt).unwrap(); + let datetime = DateTime::::from(date); + datetime.naive_local().date() + } } - #[derive(Deserialize, Debug)] + #[derive(Clone, Deserialize, Debug)] pub struct ResponsePageInfo { pub hasPreviousPage: bool, pub startCursor: Option, } - #[derive(Deserialize, Debug)] + #[derive(Clone, Deserialize, Debug)] pub struct ResponseTimelogs { pub nodes: Vec, pub pageInfo: ResponsePageInfo, } - #[derive(Deserialize, Debug)] + #[derive(Clone, Deserialize, Debug)] pub struct ResponseData { pub timelogs: ResponseTimelogs, } - #[derive(Deserialize, Debug)] + /// The response from the GitLab API with all timelogs for the given + /// time frame. + #[derive(Clone, Deserialize, Debug)] pub struct Response { pub data: ResponseData, } - - #[derive(Deserialize, Debug)] - pub struct Project { - pub group: Option, - } - - #[derive(Deserialize, Debug)] - pub struct Group { - pub fullName: String, - } - - #[derive(Deserialize, Debug)] - pub struct Epic { - pub title: String, - } } diff --git a/src/main.rs b/src/main.rs index ee161d4..ecb15b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ SOFTWARE. #![deny(rustdoc::all)] use crate::cli::CfgFile; +use crate::filtering::filter_timelogs; use crate::gitlab_api::types::{Response, ResponseNode}; use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveTime, Weekday}; use clap::Parser; @@ -51,14 +52,15 @@ use reqwest::blocking::Client; use reqwest::header::AUTHORIZATION; use serde::de::DeserializeOwned; use serde_json::json; -use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::io::ErrorKind; use std::path::PathBuf; use std::time::Duration; mod cli; +mod filtering; mod gitlab_api; +mod views; const GRAPHQL_TEMPLATE: &str = include_str!("./gitlab-query.graphql"); @@ -240,7 +242,7 @@ fn main() -> Result<(), Box> { println!("Username : {}", cfg.username()); println!("Time Span: {} - {}", cfg.after(), cfg.before()); - let res = fetch_all_results( + let data_all = fetch_all_results( cfg.username(), cfg.host(), cfg.token(), @@ -249,102 +251,24 @@ fn main() -> Result<(), Box> { ); // All dates with timelogs. - // TODO this is now obsolete. We need to refactor the "data filter layer" - let all_dates = find_dates(&res, &cfg.before(), &cfg.after()); - let week_to_logs_map = aggregate_dates_by_week(&all_dates); + let data_filtered = filter_timelogs( + &data_all, None, /* time already filtered on server */ + None, None, + ) + .collect::>(); - if week_to_logs_map.is_empty() { + if data_filtered.is_empty() { print_warning( "Empty response. Is the username correct? Does the token has read permission?", 0, ); } else { - print_all_weeks(&all_dates, &week_to_logs_map, &res); + print_all_weeks(data_filtered.as_slice()); } Ok(()) } -/// Returns a sorted list from oldest to newest date with records for the last -/// specified time range. -fn find_dates(res: &Response, before: &NaiveDate, after: &NaiveDate) -> BTreeSet { - let days = res - .data - .timelogs - .nodes - .iter() - .map(|node| parse_gitlab_datetime(&node.spentAt)) - .filter(|date| date <= before) - .filter(|date| date >= after) - .collect::>(); - - days.into_iter().collect() -} - -/// Aggregates and sorts the dates with records from the response from GitLab -/// so that we get a sorted collection of weeks and a sorted collection of each -/// day with entries per week. -fn aggregate_dates_by_week( - dates: &BTreeSet, -) -> BTreeMap<(i32 /* year */, u32 /* iso week */), BTreeSet> { - let mut week_to_dates_map = BTreeMap::new(); - - for date in dates.iter().copied() { - let week = date.iso_week().week(); - let key = (date.year(), week); - week_to_dates_map - .entry(key) - .and_modify(|set: &mut BTreeSet| { - set.insert(date); - }) - .or_insert_with(|| { - let mut set = BTreeSet::new(); - set.insert(date); - set - }); - } - - week_to_dates_map -} - -/// Parses the UTC timestring coming from GitLab in the local timezone of -/// the user. This is necessary so that entries accounted to a Monday on `00:00` -/// in CEST are not displayed as Sunday. -/// -/// # Parameters -/// - `datestring` in GitLab format such as `"2024-06-09T22:00:00Z"`. -fn parse_gitlab_datetime(datestring: &str) -> NaiveDate { - let date = DateTime::parse_from_rfc3339(datestring).unwrap(); - let date = DateTime::::from(date); - // simplify - date.naive_local().date() -} - -fn calc_total_time_per_day(date: &NaiveDate, res: &Response) -> Duration { - find_logs_of_day(date, res) - .map(|node| node.timeSpent().1) - .sum() -} - -fn sum_total_time_of_dates<'a>( - dates: impl Iterator, /* dates of that week */ - res: &Response, -) -> Duration { - dates - .map(|date| calc_total_time_per_day(date, res)) - .sum::() -} - -fn find_logs_of_day<'a>( - date: &'a NaiveDate, - res: &'a Response, -) -> impl Iterator { - res.data.timelogs.nodes.iter().filter(|node| { - let node_date = parse_gitlab_datetime(&node.spentAt); - node_date == *date - }) -} - fn print_timelog(log: &ResponseNode) { let (duration_is_positive, duration) = log.timeSpent(); print!(" "); @@ -370,12 +294,7 @@ fn print_timelog(log: &ResponseNode) { } // Print issue metadata. - let epic_name = log - .issue - .epic - .as_ref() - .map(|e| e.title.as_str()) - .unwrap_or(""); + let epic_name = log.epic_name().unwrap_or(""); let whitespace = " ".repeat(11); println!( "{whitespace}{link}", @@ -407,8 +326,8 @@ fn print_warning(msg: &str, indention: usize) { ); } -fn print_date(day: &NaiveDate, data: &Response) { - let total = calc_total_time_per_day(day, data); +fn print_date(day: &NaiveDate, nodes_of_day: &[&ResponseNode]) { + let total = views::to_time_spent_sum(nodes_of_day); let day_print = format!("{day}, {}", day.weekday()); @@ -433,16 +352,12 @@ fn print_date(day: &NaiveDate, data: &Response) { } } - for log in find_logs_of_day(day, data) { + for log in nodes_of_day { print_timelog(log); } } -fn print_week( - week: (i32 /* year */, u32 /* iso week */), - dates_of_week: &BTreeSet, - data: &Response, -) { +fn print_week(week: (i32 /* year */, u32 /* iso week */), nodes_of_week: &[&ResponseNode]) { let week_style = Style::new().bold(); let week_print = format!("WEEK {}-W{:02}", week.0, week.1); println!( @@ -450,7 +365,7 @@ fn print_week( delim = week_style.paint("======================"), week_print = week_style.paint(week_print) ); - let total_week_time = sum_total_time_of_dates(dates_of_week.iter(), data); + let total_week_time = views::to_time_spent_sum(nodes_of_week); print!( "{total_time_key} ", total_time_key = Style::new().bold().paint("Total time:") @@ -459,47 +374,53 @@ fn print_week( println!(); println!(); - for (i, date) in dates_of_week.iter().enumerate() { - print_date(date, data); + let nodes_by_day = views::to_nodes_by_day(nodes_of_week); + + for (i, (day, nodes)) in nodes_by_day.iter().enumerate() { + print_date(day, nodes); - let is_last = i == dates_of_week.len() - 1; + let is_last = i == nodes_by_day.len() - 1; if !is_last { println!(); } } } -fn print_final_summary(all_dates: &BTreeSet, res: &Response) { - let total_time = sum_total_time_of_dates(all_dates.iter(), res); +fn print_final_summary(nodes: &[&ResponseNode]) { + // Print separator. + { + println!(); + // same length as the week separator + println!("{}", "-".repeat(59)); + println!(); + } + + let total_time = views::to_time_spent_sum(nodes); + let all_days = views::to_nodes_by_day(nodes); - println!(); - // same length as the week separator - println!("{}", "-".repeat(59)); - println!(); print!( "{total_time_key} ({days_amount:>2} days with records): ", total_time_key = Style::new().bold().paint("Total time"), - days_amount = all_dates.len(), + days_amount = all_days.len(), ); print_duration(total_time, Color::Blue); println!(); + + // TODO print by epic, by issue, and by group } -fn print_all_weeks( - all_dates: &BTreeSet, - week_to_logs_map: &BTreeMap<(i32, u32), BTreeSet>, - res: &Response, -) { - for (i, (&week, dates_of_week)) in week_to_logs_map.iter().enumerate() { - let is_last = i == week_to_logs_map.len() - 1; +fn print_all_weeks(nodes: &[&ResponseNode]) { + let view = views::to_nodes_by_week(nodes); + for (i, (week, nodes_of_week)) in view.iter().enumerate() { + print_week((week.year(), week.week()), nodes_of_week); - print_week(week, dates_of_week, res); + let is_last = i == view.len() - 1; if !is_last { println!(); } } - print_final_summary(all_dates, res); + print_final_summary(nodes); } const fn duration_to_hhmm(dur: Duration) -> (u64, u64) { diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..fdfb082 --- /dev/null +++ b/src/views.rs @@ -0,0 +1,76 @@ +/* +MIT License + +Copyright (c) 2024 Philipp Schuster + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +//! Provides transform functions for different views into the data. + +use crate::gitlab_api::types::ResponseNode; +use chrono::{Datelike, IsoWeek, NaiveDate}; +use std::collections::BTreeMap; +use std::time::Duration; + +/// Returns the nodes per [`IsoWeek`]. +pub fn to_nodes_by_week<'a>( + nodes: &[&'a ResponseNode], +) -> BTreeMap> { + let weeks = nodes + .iter() + .map(|node| node.datetime().iso_week()) + .collect::>(); + + let mut map = BTreeMap::new(); + for week in weeks { + let nodes_of_week = nodes + .iter() + .filter(|node| node.datetime().iso_week() == week) + .cloned() + .collect::>(); + + map.entry(week).or_insert(nodes_of_week); + } + map +} + +/// Returns the nodes per [`NaiveDate`]. +pub fn to_nodes_by_day<'a>( + nodes: &[&'a ResponseNode], +) -> BTreeMap> { + let days = nodes.iter().map(|node| node.datetime()).collect::>(); + + let mut map = BTreeMap::new(); + for day in days { + let nodes_of_week = nodes + .iter() + .filter(|node| node.datetime() == day) + .cloned() + .collect::>(); + + map.entry(day).or_insert(nodes_of_week); + } + map +} + +/// Returns the time spent per day. +pub fn to_time_spent_sum(nodes: &[&ResponseNode]) -> Duration { + nodes.iter().map(|node| node.timeSpent().1).sum() +} From f1bae5bdd9cb82fc0661309df2433af010515495 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Tue, 3 Sep 2024 12:14:14 +0200 Subject: [PATCH 2/6] cli: dedicated fetch module This cleans up `main.rs`. --- src/fetch.rs | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 120 ++----------------------------------------- 2 files changed, 147 insertions(+), 115 deletions(-) create mode 100644 src/fetch.rs diff --git a/src/fetch.rs b/src/fetch.rs new file mode 100644 index 0000000..b72e335 --- /dev/null +++ b/src/fetch.rs @@ -0,0 +1,142 @@ +/* +MIT License + +Copyright (c) 2024 Philipp Schuster + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +//! Functionality to fetch data from the GitLab API. +//! +//! [`fetch_results`] is the entry point. + +use crate::gitlab_api::types::Response; +use chrono::{DateTime, Local, NaiveDate, NaiveTime}; +use reqwest::blocking::Client; +use reqwest::header::AUTHORIZATION; +use serde_json::json; + +const GRAPHQL_TEMPLATE: &str = include_str!("./gitlab-query.graphql"); + +/// Transforms a [`NaiveDate`] to a `DateTime`. +fn naive_date_to_local_datetime(date: NaiveDate) -> DateTime { + date.and_time(NaiveTime::MIN) + .and_local_timezone(Local) + .unwrap() +} + +/// Performs a single request against the GitLab API, getting exactly one page +/// of the paged data source. The data is filtered for the date span to make the +/// request smaller/quicker. +/// +/// # Parameters +/// - `username`: The exact GitLab username of the user. +/// - `host`: Host name of the GitLab instance without `https://` +/// - `token`: GitLab token to access the GitLab instance. Must have at least +/// READ access. +/// - `before`: Identifier from previous request to get the next page of the +/// paginated result. +/// - `start_date`: Inclusive begin date. +/// - `end_date`: Inclusive end date. +fn fetch_result( + username: &str, + host: &str, + token: &str, + before: Option<&str>, + start_date: NaiveDate, + end_date: NaiveDate, +) -> Response { + let graphql_query = GRAPHQL_TEMPLATE + .replace("%USERNAME%", username) + .replace("%BEFORE%", before.unwrap_or_default()) + // GitLab API ignores the time component and just looks at the + // date and the timezone. + .replace( + "%START_DATE%", + naive_date_to_local_datetime(start_date) + .to_string() + .as_str(), + ) + // GitLab API ignores the time component and just looks at the + // date and the timezone. + .replace( + "%END_DATE%", + naive_date_to_local_datetime(end_date).to_string().as_str(), + ); + let payload = json!({ "query": graphql_query }); + + let authorization = format!("Bearer {token}", token = token); + let url = format!("https://{host}/api/graphql", host = host); + let client = Client::new(); + + client + .post(url) + .header(AUTHORIZATION, authorization) + .json(&payload) + .send() + .unwrap() + .json::() + .unwrap() +} + +/// Fetches all results from the API with pagination in mind. +/// +/// # Parameters +/// - `username`: The exact GitLab username of the user. +/// - `host`: Host name of the GitLab instance without `https://` +/// - `token`: GitLab token to access the GitLab instance. Must have at least +/// READ access. +/// - `start_date`: Inclusive begin date. +/// - `end_date`: Inclusive end date. +pub fn fetch_results( + username: &str, + host: &str, + token: &str, + start_date: NaiveDate, + end_date: NaiveDate, +) -> Response { + let base = fetch_result(username, host, token, None, start_date, end_date); + + let mut aggregated = base; + while aggregated.data.timelogs.pageInfo.hasPreviousPage { + let mut next = fetch_result( + username, + host, + token, + Some( + &aggregated + .data + .timelogs + .pageInfo + .startCursor + .expect("Should be valid string at this point"), + ), + start_date, + end_date, + ); + + // Ordering here is not that important, happens later anyway. + next.data + .timelogs + .nodes + .extend(aggregated.data.timelogs.nodes); + aggregated = next; + } + aggregated +} diff --git a/src/main.rs b/src/main.rs index ecb15b1..af42ee7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,135 +42,25 @@ SOFTWARE. #![deny(rustdoc::all)] use crate::cli::CfgFile; +use crate::fetch::fetch_results; use crate::filtering::filter_timelogs; -use crate::gitlab_api::types::{Response, ResponseNode}; -use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveTime, Weekday}; +use crate::gitlab_api::types::ResponseNode; +use chrono::{Datelike, NaiveDate, Weekday}; use clap::Parser; use cli::CliArgs; use nu_ansi_term::{Color, Style}; -use reqwest::blocking::Client; -use reqwest::header::AUTHORIZATION; use serde::de::DeserializeOwned; -use serde_json::json; use std::error::Error; use std::io::ErrorKind; use std::path::PathBuf; use std::time::Duration; mod cli; +mod fetch; mod filtering; mod gitlab_api; mod views; -const GRAPHQL_TEMPLATE: &str = include_str!("./gitlab-query.graphql"); - -/// Transforms a [`NaiveDate`] to a `DateTime`. -fn naive_date_to_local_datetime(date: NaiveDate) -> DateTime { - date.and_time(NaiveTime::MIN) - .and_local_timezone(Local) - .unwrap() -} - -/// Performs a single request against the GitLab API, getting exactly one page -/// of the paged data source. The data is filtered for the date span to make the -/// request smaller/quicker. -/// -/// # Parameters -/// - `username`: The exact GitLab username of the user. -/// - `host`: Host name of the GitLab instance without `https://` -/// - `token`: GitLab token to access the GitLab instance. Must have at least -/// READ access. -/// - `before`: Identifier from previous request to get the next page of the -/// paginated result. -/// - `start_date`: Inclusive begin date. -/// - `end_date`: Inclusive end date. -fn fetch_result( - username: &str, - host: &str, - token: &str, - before: Option<&str>, - start_date: NaiveDate, - end_date: NaiveDate, -) -> Response { - let graphql_query = GRAPHQL_TEMPLATE - .replace("%USERNAME%", username) - .replace("%BEFORE%", before.unwrap_or_default()) - // GitLab API ignores the time component and just looks at the - // date and the timezone. - .replace( - "%START_DATE%", - naive_date_to_local_datetime(start_date) - .to_string() - .as_str(), - ) - // GitLab API ignores the time component and just looks at the - // date and the timezone. - .replace( - "%END_DATE%", - naive_date_to_local_datetime(end_date).to_string().as_str(), - ); - let payload = json!({ "query": graphql_query }); - - let authorization = format!("Bearer {token}", token = token); - let url = format!("https://{host}/api/graphql", host = host); - let client = Client::new(); - - client - .post(url) - .header(AUTHORIZATION, authorization) - .json(&payload) - .send() - .unwrap() - .json::() - .unwrap() -} - -/// Fetches all results from the API with pagination in mind. -/// -/// # Parameters -/// - `username`: The exact GitLab username of the user. -/// - `host`: Host name of the GitLab instance without `https://` -/// - `token`: GitLab token to access the GitLab instance. Must have at least -/// READ access. -/// - `start_date`: Inclusive begin date. -/// - `end_date`: Inclusive end date. -fn fetch_all_results( - username: &str, - host: &str, - token: &str, - start_date: NaiveDate, - end_date: NaiveDate, -) -> Response { - let base = fetch_result(username, host, token, None, start_date, end_date); - - let mut aggregated = base; - while aggregated.data.timelogs.pageInfo.hasPreviousPage { - let mut next = fetch_result( - username, - host, - token, - Some( - &aggregated - .data - .timelogs - .pageInfo - .startCursor - .expect("Should be valid string at this point"), - ), - start_date, - end_date, - ); - - // Ordering here is not that important, happens later anyway. - next.data - .timelogs - .nodes - .extend(aggregated.data.timelogs.nodes); - aggregated = next; - } - aggregated -} - /// Returns the path of the config file with respect to the current OS. fn config_file_path() -> Result> { #[cfg(target_family = "unix")] @@ -242,7 +132,7 @@ fn main() -> Result<(), Box> { println!("Username : {}", cfg.username()); println!("Time Span: {} - {}", cfg.after(), cfg.before()); - let data_all = fetch_all_results( + let data_all = fetch_results( cfg.username(), cfg.host(), cfg.token(), From c06ffe5735f68741273f10dd873de856a328a1a4 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Tue, 3 Sep 2024 12:26:32 +0200 Subject: [PATCH 3/6] cli: cfg This cleans up `main.rs`. --- src/cfg.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 74 ++------------------------------------ 2 files changed, 103 insertions(+), 71 deletions(-) create mode 100644 src/cfg.rs diff --git a/src/cfg.rs b/src/cfg.rs new file mode 100644 index 0000000..b6dca3d --- /dev/null +++ b/src/cfg.rs @@ -0,0 +1,100 @@ +/* +MIT License + +Copyright (c) 2024 Philipp Schuster + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +//! Module for obtaining the effective configuration, based on the configuration +//! file and CLI parameters. +//! +//! [`get_cfg`] is the entry point. + +use crate::cli::{CfgFile, CliArgs}; +use crate::{cli, print_warning}; +use clap::Parser; +use serde::de::DeserializeOwned; +use std::error::Error; +use std::io::ErrorKind; +use std::path::PathBuf; + +/// Returns the path of the config file with respect to the current OS. +fn config_file_path() -> Result> { + #[cfg(target_family = "unix")] + let config_os_dir = { + // First look for XDG_CONFIG_HOME, then fall back to HOME + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + let home = std::env::var("XDG_CONFIG_HOME").unwrap_or(std::env::var("HOME")?); + PathBuf::from(home).join(".config") + }; + #[cfg(target_family = "windows")] + let config_os_dir = PathBuf::from(std::env::var("LOCALAPPDATA")?); + + let config_dir = config_os_dir.join("gitlab-timelogs"); + Ok(config_dir.join("config.toml")) +} + +/// Reads the config file and parses it from TOML. +/// On UNIX, it uses ` +fn read_config_file() -> Result> { + let config_file = config_file_path()?; + let content = match std::fs::read_to_string(&config_file) { + Ok(c) => c, + Err(e) => { + match e.kind() { + ErrorKind::NotFound => {} + _ => print_warning( + &format!( + "Failed to read config file at {}: {e}", + config_file.display() + ), + 0, + ), + } + + // Treat failure to read a config file as the empty config file. + String::new() + } + }; + + Ok(toml::from_str(&content)?) +} + +/// Parses the command line options but first, reads the config file. If certain +/// command line options are not present, they are taken from the config file. +/// +/// This is a workaround that clap has no built-in support for a config file +/// that serves as source for command line options by itself. The focus is +/// also on the natural error reporting by clap. +pub fn get_cfg() -> Result> { + let config_content = read_config_file::()?; + let config_args: Vec<(String, String)> = config_content.to_cli_args(); + let mut all_args = std::env::args().collect::>(); + + // Push config options as arguments, before parsing them in clap. + for (opt_name, opt_value) in config_args { + if !all_args.contains(&opt_name) { + all_args.push(opt_name); + all_args.push(opt_value); + } + } + + Ok(cli::CliArgs::parse_from(all_args)) +} diff --git a/src/main.rs b/src/main.rs index af42ee7..cc597db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,92 +41,24 @@ SOFTWARE. #![deny(missing_debug_implementations)] #![deny(rustdoc::all)] -use crate::cli::CfgFile; +use crate::cfg::get_cfg; use crate::fetch::fetch_results; use crate::filtering::filter_timelogs; use crate::gitlab_api::types::ResponseNode; use chrono::{Datelike, NaiveDate, Weekday}; -use clap::Parser; -use cli::CliArgs; use nu_ansi_term::{Color, Style}; -use serde::de::DeserializeOwned; use std::error::Error; -use std::io::ErrorKind; -use std::path::PathBuf; use std::time::Duration; +mod cfg; mod cli; mod fetch; mod filtering; mod gitlab_api; mod views; -/// Returns the path of the config file with respect to the current OS. -fn config_file_path() -> Result> { - #[cfg(target_family = "unix")] - let config_os_dir = { - // First look for XDG_CONFIG_HOME, then fall back to HOME - // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - let home = std::env::var("XDG_CONFIG_HOME").unwrap_or(std::env::var("HOME")?); - PathBuf::from(home).join(".config") - }; - #[cfg(target_family = "windows")] - let config_os_dir = PathBuf::from(std::env::var("LOCALAPPDATA")?); - - let config_dir = config_os_dir.join("gitlab-timelogs"); - Ok(config_dir.join("config.toml")) -} - -/// Reads the config file and parses it from TOML. -/// On UNIX, it uses ` -fn read_config_file() -> Result> { - let config_file = config_file_path()?; - let content = match std::fs::read_to_string(&config_file) { - Ok(c) => c, - Err(e) => { - match e.kind() { - ErrorKind::NotFound => {} - _ => print_warning( - &format!( - "Failed to read config file at {}: {e}", - config_file.display() - ), - 0, - ), - } - - // Treat failure to read a config file as the empty config file. - String::new() - } - }; - - Ok(toml::from_str(&content)?) -} - -/// Parses the command line options but first, reads the config file. If certain -/// command line options are not present, they are taken from the config file. -/// -/// This is a workaround that clap has no built-in support for a config file -/// that serves as source for command line options by itself. The focus is -/// also on the natural error reporting by clap. -fn get_cli_cfg() -> Result> { - let config_content = read_config_file::()?; - let config_args: Vec<(String, String)> = config_content.to_cli_args(); - let mut all_args = std::env::args().collect::>(); - - // Push config options as arguments, before parsing them in clap. - for (opt_name, opt_value) in config_args { - if !all_args.contains(&opt_name) { - all_args.push(opt_name); - all_args.push(opt_value); - } - } - - Ok(cli::CliArgs::parse_from(all_args)) -} - fn main() -> Result<(), Box> { - let cfg = get_cli_cfg()?; + let cfg = get_cfg()?; assert!(cfg.before() >= cfg.after()); println!("Host : {}", cfg.host()); println!("Username : {}", cfg.username()); From d2b67b11bf4b1496eb61576c9fd945dd17a517d6 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Tue, 3 Sep 2024 12:20:37 +0200 Subject: [PATCH 4/6] cli: misc --- src/gitlab_api.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gitlab_api.rs b/src/gitlab_api.rs index 25c92c0..87de874 100644 --- a/src/gitlab_api.rs +++ b/src/gitlab_api.rs @@ -21,6 +21,10 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + +//! Typings for the GitLab API. These types are specific to the graphql query +//! used by this tool. + #[allow(non_snake_case)] pub mod types { use chrono::{DateTime, Local, NaiveDate}; From 4c6dc73273fbcb57672a7ccfcf405ef42c2e634f Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Tue, 3 Sep 2024 12:12:03 +0200 Subject: [PATCH 5/6] flake: update --- flake.lock | 23 +++++++++-------------- flake.nix | 5 ++++- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/flake.lock b/flake.lock index 7e011d5..346d94b 100644 --- a/flake.lock +++ b/flake.lock @@ -1,17 +1,12 @@ { "nodes": { "crane": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, "locked": { - "lastModified": 1720025378, - "narHash": "sha256-zlIdj0oLvMEHlllP/7tvY+kE987xsN4FPux6WHSOh00=", + "lastModified": 1725125250, + "narHash": "sha256-CB20rDD5eHikF6mMTTJdwPP1qvyoiyyw1RDUzwIaIF8=", "owner": "ipetkov", "repo": "crane", - "rev": "087e08a41009bf083d51ab35d8e30b1b7eafa7b0", + "rev": "96fd12c7100e9e05fa1a0a5bd108525600ce282f", "type": "github" }, "original": { @@ -28,11 +23,11 @@ ] }, "locked": { - "lastModified": 1719994518, - "narHash": "sha256-pQMhCCHyQGRzdfAkdJ4cIWiw+JNuWsTX7f0ZYSyz0VY=", + "lastModified": 1725234343, + "narHash": "sha256-+ebgonl3NbiKD2UD0x4BszCZQ6sTfL4xioaM49o5B3Y=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "9227223f6d922fee3c7b190b2cc238a99527bbb7", + "rev": "567b938d64d4b4112ee253b9274472dc3a346eb6", "type": "github" }, "original": { @@ -43,11 +38,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1719956923, - "narHash": "sha256-nNJHJ9kfPdzYsCOlHOnbiiyKjZUW5sWbwx3cakg3/C4=", + "lastModified": 1725001927, + "narHash": "sha256-eV+63gK0Mp7ygCR0Oy4yIYSNcum2VQwnZamHxYTNi+M=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "706eef542dec88cc0ed25b9075d3037564b2d164", + "rev": "6e99f2a27d600612004fbd2c3282d614bfee6421", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 7f57423..4f6c4f5 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,6 @@ inputs = { crane.url = "github:ipetkov/crane/master"; - crane.inputs.nixpkgs.follows = "nixpkgs"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; @@ -76,6 +75,10 @@ devShells = { default = pkgs.mkShell { inputsFrom = [ self'.packages.default ]; + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ + pkgs.openssl + ]; }; }; formatter = pkgs.nixpkgs-fmt; From 43d312f1ef74b401a7b1cb68d1536d27b0a7b468 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Tue, 3 Sep 2024 12:30:15 +0200 Subject: [PATCH 6/6] cargo: update --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index baeb5e2..187a887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,9 +134,9 @@ checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "cc" -version = "1.1.14" +version = "1.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d2eb3cd3d1bf4529e31c215ee6f93ec5a3d536d9f578f93d9d33ee19562932" +checksum = "57b6a275aa2903740dc87da01c62040406b8812552e97129a63ea8850a17c6e6" dependencies = [ "shlex", ] @@ -551,9 +551,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", "hashbrown", @@ -674,9 +674,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.3" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" dependencies = [ "memchr", ] @@ -860,9 +860,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "a85d50532239da68e9addb745ba38ff4612a242c1c7ceea689c4bc7c2f43c36f" dependencies = [ "bitflags", "errno", @@ -902,9 +902,9 @@ checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.102.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "84678086bd54edf2b415183ed7a94d0efb049f1b646a33e22a36f3794be6ae56" dependencies = [ "ring", "rustls-pki-types", @@ -1053,9 +1053,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.76" +version = "2.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" dependencies = [ "proc-macro2", "quote", @@ -1132,9 +1132,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.3" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes",