gitlab_time_report_cli/
main.rs

1//! This crate provides a Command Line Interface to fetch time logs from a GitLab project and
2//! generate statistics and charts from them.
3
4#![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
90/// Fetches the time logs and related data for a GitLab repository.
91pub(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
108/// Runs validation on the entered time logs with all validators and outputs the result to the console.
109pub(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    // Print summary and return when detailed listing is not desired
131    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
195/// Creates the graphs based on the time logs and the chart options.
196pub(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
314/// Checks if a list of labels has been given.
315/// If yes, create a [`HashSet`] from the labels and return it, including an "Others" label for the
316/// rest of the labels which are not included in the set.
317pub(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    // Only use the "Others" label if there are labels selected.
326    let other_label = selected_labels.as_ref().map(|_| Label {
327        title: "Others".to_string(),
328    });
329
330    (selected_labels, other_label)
331}
332
333/// Prints a message regarding access token. URL passed to this function should be without a
334/// protocol prefix (`https://`)
335fn 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}