1#![cfg(not(tarpaulin_include))]
5mod arguments;
6mod print_table;
7
8use arguments::Command;
9use chrono::NaiveDate;
10use clap::Parser;
11use gitlab_time_report::charts::{BurndownType, ChartSettingError};
12use gitlab_time_report::dashboard::create_html;
13use gitlab_time_report::model::{Label, Milestone, Project, TimeLog, TrackableItemKind};
14use gitlab_time_report::validation::{TimeLogValidator, ValidationProblem};
15use gitlab_time_report::{FetchOptions, QueryError, TimeDeltaExt, charts, create_csv, filters};
16use std::collections::{BTreeMap, HashSet};
17
18fn main() -> Result<(), String> {
19 let cli = arguments::Arguments::parse();
20 println!("Fetching time logs from '{}'...", cli.url);
21 let project = fetch_project_data(&cli.url, cli.token).map_err(|e| e.to_string())?;
22
23 let start_date_for_validation: Option<NaiveDate> = match &cli.command {
24 Some(Command::Charts { chart_options } | Command::Dashboard { chart_options, .. }) => {
25 chart_options.start_date
26 }
27 _ => None,
28 };
29
30 validate_time_logs(
31 &project.time_logs,
32 cli.validation_details,
33 start_date_for_validation,
34 cli.validation_max_hours,
35 );
36
37 match &cli.command {
38 Some(Command::Export { output }) => {
39 create_csv(&project.time_logs, output.clone()).map_err(|e| e.to_string())?;
40 println!("CSV created at {}", output.display());
41 }
42 Some(Command::Charts { chart_options }) => {
43 let (selected_labels, other_label) = create_label_options(cli.labels);
44 create_charts(
45 &project.time_logs,
46 chart_options,
47 selected_labels.as_ref(),
48 other_label.as_ref(),
49 &project.name,
50 )
51 .map_err(|e| e.to_string())?;
52 }
53 Some(Command::Dashboard { chart_options }) => {
54 let (selected_labels, other_label) = create_label_options(cli.labels);
55 create_charts(
56 &project.time_logs,
57 chart_options,
58 selected_labels.as_ref(),
59 other_label.as_ref(),
60 &project.name,
61 )
62 .map_err(|e| e.to_string())?;
63
64 create_html(
65 &project.time_logs,
66 &chart_options.output,
67 selected_labels.as_ref(),
68 other_label.as_ref(),
69 &project.name,
70 )
71 .map_err(|e| e.to_string())?;
72 println!("Dashboard 'dashboard.html' successfully created.");
73 }
74 None => {
75 print_table::print_timelogs_in_timeframes_by_user(&project.time_logs);
76
77 let (selected_labels, other_label) = create_label_options(cli.labels);
78 print_table::print_total_time_by_label(
79 &project.time_logs,
80 selected_labels.as_ref(),
81 other_label.as_ref(),
82 );
83
84 print_table::print_total_time_by_milestone(&project.time_logs);
85 }
86 }
87 Ok(())
88}
89
90pub(crate) fn fetch_project_data(
92 url: &str,
93 token: Option<String>,
94) -> Result<Project, Box<dyn std::error::Error>> {
95 let fetch_options = FetchOptions::new(url, token)?;
96
97 let project_result = gitlab_time_report::fetch_project_time_logs(&fetch_options);
98 match project_result {
99 Ok(project) => Ok(project),
100 Err(QueryError::ProjectNotFound(url)) => {
101 print_project_not_found(&url);
102 Err(QueryError::ProjectNotFound(url).into())
103 }
104 Err(error) => Err(error.into()),
105 }
106}
107
108pub(crate) fn validate_time_logs(
110 time_logs: &[TimeLog],
111 show_validation_details: bool,
112 start_date: Option<chrono::NaiveDate>,
113 max_hours: u16,
114) {
115 let mut validator = TimeLogValidator::new()
116 .with_validator(gitlab_time_report::validation::ExcessiveHoursValidator::new(max_hours))
117 .with_validator(gitlab_time_report::validation::HasSummaryValidator)
118 .with_validator(gitlab_time_report::validation::NoFutureDateValidator)
119 .with_validator(gitlab_time_report::validation::DuplicatesValidator::new());
120
121 if let Some(start_date) = start_date {
122 validator = validator.with_validator(
123 gitlab_time_report::validation::BeforeStartDateValidator::new(start_date),
124 );
125 }
126
127 let results = validator.validate(time_logs);
128 let number_of_problems = results.iter().filter(|r| !r.is_valid()).count();
129
130 if !show_validation_details {
132 match results.is_empty() {
133 true => println!("No problems found in the time logs of the project."),
134 false => println!(
135 "\n{number_of_problems} problems found in the time logs of the project. To see the problems, run with --validation-details",
136 ),
137 }
138 return;
139 }
140
141 println!(
142 "\
143=============================================
144 Time Logs with validation problems
145============================================="
146 );
147
148 for result in results {
149 if result.is_valid() {
150 continue;
151 }
152
153 let time_log = result.time_log;
154 let trackable_item_text = match &time_log.trackable_item.kind {
155 TrackableItemKind::Issue(_) => "Issue #",
156 TrackableItemKind::MergeRequest(_) => "Merge Request !",
157 };
158
159 println!(
160 "{trackable_item_text}{}: {}",
161 time_log.trackable_item.common.id, time_log.trackable_item.common.title,
162 );
163 println!(
164 "{}, ({}), {}: {}",
165 time_log.spent_at.date_naive(),
166 time_log.time_spent.to_hm_string(),
167 time_log.user.name,
168 time_log.summary.as_deref().unwrap_or_default()
169 );
170
171 for problem in &result.problems {
172 match problem {
173 ValidationProblem::ExcessiveHours { max_hours } => {
174 println!("Time spent exceeds maximum of {max_hours} hours");
175 }
176 ValidationProblem::MissingSummary => println!("No summary was entered"),
177 ValidationProblem::FutureDate => println!("Date is in the future"),
178 ValidationProblem::DuplicateEntry => println!("Duplicate entry"),
179 ValidationProblem::BeforeStartDate { start_date } => println!(
180 "Date is before project start date: {}",
181 start_date.format("%Y-%m-%d")
182 ),
183 }
184 println!();
185 }
186 }
187 println!(
188 "\
189=============================================
190Total Problems found: {number_of_problems}
191=============================================\n",
192 );
193}
194
195pub(crate) fn create_charts(
197 time_logs: &[TimeLog],
198 options: &arguments::ChartOptionsArgs,
199 selected_labels: Option<&HashSet<String>>,
200 other_label: Option<&Label>,
201 repository_name: &str,
202) -> Result<(), ChartSettingError> {
203 let burndown_options = charts::BurndownOptions::new(
204 time_logs,
205 options.weeks_per_sprint,
206 options.sprints,
207 options.hours_per_person,
208 options.start_date,
209 )?;
210 let mut render_options = charts::RenderOptions::new(
211 options.width,
212 options.height,
213 options.theme_json.as_deref(),
214 &options.output,
215 repository_name,
216 )?;
217
218 println!("Creating Bar Chart for Hours spent by Users...");
219 let by_user: BTreeMap<_, _> = filters::group_by_user(time_logs).collect();
220 charts::create_bar_chart(
221 by_user.clone(),
222 "Hours spent by Users",
223 "Users",
224 &mut render_options,
225 )?;
226
227 println!("Creating Burndown Charts...");
228 charts::create_burndown_chart(
229 time_logs,
230 &BurndownType::PerPerson,
231 &burndown_options,
232 &mut render_options,
233 )?;
234 charts::create_burndown_chart(
235 time_logs,
236 &BurndownType::Total,
237 &burndown_options,
238 &mut render_options,
239 )?;
240
241 println!("Creating Bar Chart for Hours spent by Milestones...");
242 let by_milestone = filters::group_by_milestone(time_logs).collect();
243 charts::create_bar_chart::<Milestone>(
244 by_milestone,
245 "Hours spent by Milestones",
246 "Milestones",
247 &mut render_options,
248 )?;
249
250 println!("Creating Pie Chart for Hours spent by Labels...");
251 let by_label: BTreeMap<_, _> =
252 filters::group_by_label(time_logs, selected_labels, other_label).collect();
253 charts::create_pie_chart(
254 by_label.clone(),
255 "Hours spent by Labels",
256 &mut render_options,
257 )?;
258
259 println!("Creating Bar Chart for Hours spent by Label and User...");
260 let by_label_and_user = by_label
261 .clone()
262 .into_iter()
263 .map(|(label, logs)| {
264 let time_by_label = filters::group_by_user(logs).collect();
265 (label, time_by_label)
266 })
267 .collect::<BTreeMap<Option<_>, BTreeMap<_, Vec<_>>>>();
268 charts::create_grouped_bar_chart(
269 by_label_and_user,
270 "Hours spent by Label and User",
271 50.0,
272 &mut render_options,
273 )?;
274
275 println!("Creating Bar Chart of Estimates and actual time by Labels...");
276 charts::create_estimate_chart::<Label>(
277 by_label,
278 "Estimates and actual time per Label",
279 &mut render_options,
280 )?;
281
282 println!("Creating Pie Chart for Hours spent by Issue or Merge Request...");
283 let by_type: BTreeMap<_, _> = filters::group_by_type(time_logs).collect();
284 let by_type_refs: BTreeMap<_, _> = by_type.iter().map(|(k, v)| (k, v.clone())).collect();
285 charts::create_pie_chart(
286 by_type_refs.clone(),
287 "Hours spent by Issue or Merge Request",
288 &mut render_options,
289 )?;
290
291 println!("Creating Bar Chart for Hours spent by Issue or Merge Request and User...");
292 let by_type_and_user = by_type_refs
293 .iter()
294 .map(|(kind, logs)| {
295 let time_by_label = filters::group_by_user(logs.clone()).collect();
296 (kind, time_by_label)
297 })
298 .collect::<BTreeMap<_, BTreeMap<_, Vec<_>>>>();
299 charts::create_grouped_bar_chart(
300 by_type_and_user,
301 "Hours spent by Issue or Merge Request and User",
302 0.0,
303 &mut render_options,
304 )?;
305
306 println!(
307 "Charts successfully created in directory '{}'.",
308 options.output.display()
309 );
310
311 Ok(())
312}
313
314pub(crate) fn create_label_options(
318 labels: Vec<String>,
319) -> (Option<HashSet<String>>, Option<Label>) {
320 let selected_labels = match labels.is_empty() {
321 true => None,
322 false => Some(HashSet::from_iter(labels)),
323 };
324
325 let other_label = selected_labels.as_ref().map(|_| Label {
327 title: "Others".to_string(),
328 });
329
330 (selected_labels, other_label)
331}
332
333fn print_project_not_found(url: &str) {
336 let (host, path) = url.split_once('/').expect("Should be a URL with one `/`");
337 eprintln!("\
338Project \"{url}\" could not be found. If the project visibility is set to 'internal' or 'private', \
339you need to provide a GitLab access token with the 'read_api' permission and \
340specify it with the --token option.
341
342You can use a personal, group or project access token. Create a new one with one of the following URLs:
343Personal: https://{host}/-/user_settings/personal_access_tokens?name=GitLab+Time-Report&scopes=read_api
344Project: https://{host}/{path}/-/settings/access_tokens
345Group: Navigate to your group settings => Access Tokens => Generate new token\n");
346}