From 0fdb7c5606bb236c2dad4889eda5cd1d6faaf922 Mon Sep 17 00:00:00 2001 From: nixargh Date: Sun, 14 Jan 2024 23:27:47 +0300 Subject: [PATCH] v1.1.0 add validation of records --- .gitignore | 3 + CHANGELOG.md | 8 ++ file.go | 69 ++++++++++++++ jira.go | 48 ++++++++++ main.go | 264 ++++++--------------------------------------------- secret.go | 37 ++++++++ timesheet.go | 210 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 402 insertions(+), 237 deletions(-) create mode 100644 file.go create mode 100644 jira.go create mode 100644 secret.go create mode 100644 timesheet.go diff --git a/.gitignore b/.gitignore index 4bfc4f7..95775e8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,8 @@ # compiled binary tired +# test data +test_data/* + # vim *.sw? diff --git a/CHANGELOG.md b/CHANGELOG.md index de2fe93..2c6bf08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2024-01-14 +### Changed +- Split code onto files. + +### Added +- Records validation. +- Records number. + ## [1.0.2] - 2023-08-03 ### Fixed - Skip unfinished lines. diff --git a/file.go b/file.go new file mode 100644 index 0000000..7ea6672 --- /dev/null +++ b/file.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +var marker string = ">>> TIRED <<<" + +func readTimesheet(path string) []string { + clog.WithFields(log.Fields{"path": path}).Info("Reading timesheet.") + + absPath, _ := filepath.Abs(path) + content, err := ioutil.ReadFile(absPath) + + if err != nil { + clog.WithFields(log.Fields{"error": err}).Fatal("Failed to read timesheet.") + } + + timesheet := strings.Split(string(content), "\n") + + return timesheet +} + +func writeTimesheet(path string, content []string) { + clog.WithFields(log.Fields{"path": path}).Info("Updating timesheet file.") + + absPath, _ := filepath.Abs(path) + bkpPath := fmt.Sprintf("%s.bak", absPath) + + // Move old file as backup + os.Rename(absPath, bkpPath) + + // create the file + f, err := os.Create(absPath) + if err != nil { + clog.WithFields(log.Fields{"error": err}).Fatal("Failed to create the file.") + } + + defer f.Close() + + // write a string + for i, line := range content { + // Remove marker + if line == marker { + continue + } + + _, lErr := f.WriteString(line + "\n") + + if lErr != nil { + clog.WithFields(log.Fields{ + "number": i, + "error": lErr, + }).Fatal("Failed to write the line.") + } + } + + // Re-add marker to the end of file + _, lErr := f.WriteString(marker) + if lErr != nil { + clog.WithFields(log.Fields{"error": lErr}).Fatal("Failed to write the marker.") + } +} diff --git a/jira.go b/jira.go new file mode 100644 index 0000000..e6ee1a4 --- /dev/null +++ b/jira.go @@ -0,0 +1,48 @@ +package main + +import ( + "strings" + + jira "github.com/andygrunwald/go-jira" + log "github.com/sirupsen/logrus" +) + +func sendWorkRecords(jiraClient *jira.Client, records []WorkRecord, dry bool) int { + errCount := 0 + + clog.Info("Sending work records to Jira.") + + for _, record := range records { + // Check the issue exists + issue, _, err := jiraClient.Issue.Get(record.Issue, nil) + + if err != nil { + errShort := strings.Split(err.Error(), ":")[0] + clog.WithFields(log.Fields{"issue": record.Issue, "error": errShort}).Error("Can't get access to the issue.") + errCount++ + continue + } + clog.WithFields(log.Fields{"issue": record.Issue, "worklog_total": issue.Fields.Worklog.Total, "summary": issue.Fields.Summary}).Debug("Issue found.") + + // Send work log + jiraStartTime := jira.Time(record.ParsedStartTime) + + workRec := jira.WorklogRecord{ + Comment: record.Comment, + Started: &jiraStartTime, + TimeSpentSeconds: record.Duration, + } + + if dry == false { + _, _, aErr := jiraClient.Issue.AddWorklogRecord(record.Issue, &workRec) + if err != nil { + clog.WithFields(log.Fields{"issue": record.Issue, "error": aErr}).Error("Failed to add Work Record to the issue.") + errCount++ + continue + } + } + clog.WithFields(log.Fields{"dry": dry, "issue": record.Issue, "worklog_total": issue.Fields.Worklog.Total, "summary": issue.Fields.Summary}).Info("Work Record added.") + } + + return errCount +} diff --git a/main.go b/main.go index b549c8c..15d5cb4 100644 --- a/main.go +++ b/main.go @@ -3,39 +3,18 @@ package main import ( "flag" "fmt" - "io/ioutil" "os" - "path/filepath" - "strings" - "syscall" "time" - "golang.org/x/exp/slices" - "golang.org/x/term" - - "github.com/zalando/go-keyring" - jira "github.com/andygrunwald/go-jira" log "github.com/sirupsen/logrus" // "github.com/pkg/profile" ) -var version string = "1.0.2" +var version string = "1.1.0" var clog *log.Entry -var marker string = ">>> TIRED <<<" - -type WorkRecord struct { - Date string - StartTime string - EndTime string - Issue string - Comment string - ParsedStartTime time.Time - Duration int -} - func main() { // defer profile.Start().Stop() @@ -53,7 +32,7 @@ func main() { flag.StringVar(×heet, "timesheet", "", "Full path to timesheet file") flag.BoolVar(&debug, "debug", false, "Log debug messages") flag.BoolVar(&dry, "dry", false, "Do a 'dry' run, without real records sending") - flag.BoolVar(&showVersion, "version", false, "FunVPN version") + flag.BoolVar(&showVersion, "version", false, "TIme REcorDer version") flag.Parse() @@ -65,7 +44,7 @@ func main() { os.Exit(0) } - if debug == true { + if debug { log.SetLevel(log.DebugLevel) } else { log.SetLevel(log.InfoLevel) @@ -84,6 +63,10 @@ func main() { clog.Fatal("'-timesheet' option is mandatory.") } + if dry { + clog.Warning("Running in 'dry' mode.") + } + if url == "" { url = promptForSecret("url") } @@ -114,18 +97,30 @@ func main() { tsFileStat, _ := os.Stat(timesheet) timesheetCont := readTimesheet(timesheet) - clog.WithFields(log.Fields{"number": len(timesheetCont)}).Info("Timesheet file total lines number.") + clog.WithFields(log.Fields{ + "number": len(timesheetCont), + }).Info("Timesheet file total lines number.") actualWorkRecords := getActualWorkRecords(timesheetCont) - clog.WithFields(log.Fields{"number": len(actualWorkRecords)}).Info("Actual work records number.") + clog.WithFields(log.Fields{ + "number": len(actualWorkRecords), + }).Info("Actual work records number.") if len(actualWorkRecords) > 0 { - workRecords := parseWorkRecords(actualWorkRecords) + workRecords, parseErrCount := parseWorkRecords(actualWorkRecords) + if parseErrCount > 0 { + clog.WithFields(log.Fields{ + "valid": len(workRecords), + "invalid": parseErrCount, + }).Fatal("Timeshit parsing finished with errors.") + } errCount := sendWorkRecords(jiraClient, workRecords, dry) if errCount > 0 { - clog.WithFields(log.Fields{"number": errCount}).Fatal("Jira Time Reporter finished with errors.") + clog.WithFields(log.Fields{ + "number": errCount, + }).Fatal("Jira Time Reporter finished with errors.") } if dry == false { @@ -134,7 +129,10 @@ func main() { // Compare timesheet file size before and after changes tsFileStatNew, _ := os.Stat(timesheet) if tsFileStatNew.Size() != tsFileStat.Size() { - clog.WithFields(log.Fields{"before": tsFileStat.Size(), "after": tsFileStatNew.Size()}).Fatal("Timesheet file size changed.") + clog.WithFields(log.Fields{ + "before": tsFileStat.Size(), + "after": tsFileStatNew.Size(), + }).Fatal("Timesheet file size changed.") } } } else { @@ -144,211 +142,3 @@ func main() { clog.Info("Jira Time Reporter job is finished.") os.Exit(0) } - -func promptForSecret(secret string) string { - service := "tired" - var secretValue string - var err error - - secretValue, err = keyring.Get(service, secret) - - if err == nil && secretValue != "" { - clog.WithFields(log.Fields{"secret": secret}).Info("Got secret value from keyring.") - return secretValue - } - - fmt.Printf("New '%v' value: ", secret) - bytespw, _ := term.ReadPassword(int(syscall.Stdin)) - secretValue = string(bytespw) - fmt.Print("\n") - - err = keyring.Set(service, secret, secretValue) - - if err != nil { - clog.WithFields(log.Fields{"secret": secret, "error": err}).Fatal("Can't save password to keyring.") - } - - clog.WithFields(log.Fields{"secret": secret}).Info("Secret saved to keyring.") - return secretValue -} - -func readTimesheet(path string) []string { - clog.WithFields(log.Fields{"path": path}).Info("Reading timesheet.") - - absPath, _ := filepath.Abs(path) - content, err := ioutil.ReadFile(absPath) - - if err != nil { - clog.WithFields(log.Fields{"error": err}).Fatal("Failed to read timesheet.") - } - - timesheet := strings.Split(string(content), "\n") - - return timesheet -} - -func writeTimesheet(path string, content []string) { - clog.WithFields(log.Fields{"path": path}).Info("Updating timesheet file.") - - absPath, _ := filepath.Abs(path) - bkpPath := fmt.Sprintf("%s.bak", absPath) - - // Move old file as backup - os.Rename(absPath, bkpPath) - - // create the file - f, err := os.Create(absPath) - if err != nil { - clog.WithFields(log.Fields{"error": err}).Fatal("Failed to create the file.") - } - - defer f.Close() - - // write a string - for i, line := range content { - // Remove marker - if line == marker { - continue - } - - _, lErr := f.WriteString(line + "\n") - - if lErr != nil { - clog.WithFields(log.Fields{"number": i, "error": lErr}).Fatal("Failed to write the line.") - } - } - - // Re-add marker to the end of file - _, lErr := f.WriteString(marker) - if lErr != nil { - clog.WithFields(log.Fields{"error": lErr}).Fatal("Failed to write the marker.") - } -} - -func getActualWorkRecords(timesheetCont []string) []string { - clog.Info("Looking for actual work records.") - var actualWorkRecords []string - - for i := len(timesheetCont) - 1; i >= 0; i-- { - wr := strings.TrimSpace(timesheetCont[i]) - if len(wr) == 0 || strings.HasPrefix(wr, "#") { - continue - } - - if wr == ">>> TIRED <<<" { - break - } - - clog.WithFields(log.Fields{"work record": wr}).Debug("New work record.") - actualWorkRecords = append(actualWorkRecords, wr) - } - - slices.Reverse(actualWorkRecords) - return actualWorkRecords -} - -func parseWorkRecords(records []string) []WorkRecord { - var workRecords []WorkRecord - clog.Info("Parsing actual work records.") - - // Location for time - location := getTimeLocation() - - for _, record := range records { - // Read original fileds - fields := strings.SplitN(record, ",", 5) - - var wr WorkRecord - wr.Date = fields[0] - wr.StartTime = fields[1] - wr.EndTime = fields[2] - wr.Issue = fields[3] - wr.Comment = strings.ReplaceAll(fields[4], "\"", "") - - // Validations - if wr.Date == "" || wr.StartTime == "" || wr.EndTime == "" || wr.Issue == "" || wr.Comment == "" { - clog.WithFields(log.Fields{"record": wr}).Warning("Some fields are empty, skipping the record.") - continue - } - - // Add duration - startDateTimeString := fmt.Sprintf("%s %s:00", wr.Date, wr.StartTime) - startDateTime, serr := time.ParseInLocation(time.DateTime, startDateTimeString, location) - if serr != nil { - clog.WithFields(log.Fields{"error": serr}).Fatal("Failed to parse start time.") - } - - // Required for record - wr.ParsedStartTime = startDateTime - - endDateTimeString := fmt.Sprintf("%s %s:00", wr.Date, wr.EndTime) - endDateTime, eerr := time.ParseInLocation(time.DateTime, endDateTimeString, location) - if eerr != nil { - clog.WithFields(log.Fields{"error": eerr}).Fatal("Failed to parse end time.") - } - - wr.Duration = int(endDateTime.Sub(startDateTime).Seconds()) - - clog.WithFields(log.Fields{"record": wr}).Debug("Record fields.") - - workRecords = append(workRecords, wr) - } - - return workRecords -} - -func getTimeLocation() *time.Location { - timezoneRaw, err := ioutil.ReadFile("/etc/timezone") - if err != nil { - clog.WithFields(log.Fields{"error": err}).Fatal("Failed to read '/etc/timezone'.") - } - timezone := strings.Trim(string(timezoneRaw), "\n") - clog.WithFields(log.Fields{"timezone": timezone}).Debug("Detected timezone.") - - location, err := time.LoadLocation(timezone) - if err != nil { - clog.WithFields(log.Fields{"error": err}).Fatal("Can't get time zone.") - } - - return location -} - -func sendWorkRecords(jiraClient *jira.Client, records []WorkRecord, dry bool) int { - errCount := 0 - - clog.Info("Sending work records to Jira.") - - for _, record := range records { - // Check the issue exists - issue, _, err := jiraClient.Issue.Get(record.Issue, nil) - - if err != nil { - errShort := strings.Split(err.Error(), ":")[0] - clog.WithFields(log.Fields{"issue": record.Issue, "error": errShort}).Error("Can't get access to the issue.") - errCount++ - continue - } - clog.WithFields(log.Fields{"issue": record.Issue, "worklog_total": issue.Fields.Worklog.Total, "summary": issue.Fields.Summary}).Debug("Issue found.") - - // Send work log - jiraStartTime := jira.Time(record.ParsedStartTime) - - workRec := jira.WorklogRecord{ - Comment: record.Comment, - Started: &jiraStartTime, - TimeSpentSeconds: record.Duration, - } - - if dry == false { - _, _, aErr := jiraClient.Issue.AddWorklogRecord(record.Issue, &workRec) - if err != nil { - clog.WithFields(log.Fields{"issue": record.Issue, "error": aErr}).Error("Failed to add Work Record to the issue.") - errCount++ - continue - } - } - clog.WithFields(log.Fields{"dry": dry, "issue": record.Issue, "worklog_total": issue.Fields.Worklog.Total, "summary": issue.Fields.Summary}).Info("Work Record added.") - } - - return errCount -} diff --git a/secret.go b/secret.go new file mode 100644 index 0000000..423b83b --- /dev/null +++ b/secret.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "syscall" + + log "github.com/sirupsen/logrus" + "github.com/zalando/go-keyring" + "golang.org/x/term" +) + +func promptForSecret(secret string) string { + service := "tired" + var secretValue string + var err error + + secretValue, err = keyring.Get(service, secret) + + if err == nil && secretValue != "" { + clog.WithFields(log.Fields{"secret": secret}).Info("Got secret value from keyring.") + return secretValue + } + + fmt.Printf("New '%v' value: ", secret) + bytespw, _ := term.ReadPassword(int(syscall.Stdin)) + secretValue = string(bytespw) + fmt.Print("\n") + + err = keyring.Set(service, secret, secretValue) + + if err != nil { + clog.WithFields(log.Fields{"secret": secret, "error": err}).Fatal("Can't save password to keyring.") + } + + clog.WithFields(log.Fields{"secret": secret}).Info("Secret saved to keyring.") + return secretValue +} diff --git a/timesheet.go b/timesheet.go new file mode 100644 index 0000000..0e031c3 --- /dev/null +++ b/timesheet.go @@ -0,0 +1,210 @@ +package main + +import ( + "fmt" + "io/ioutil" + "regexp" + "slices" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +var timeNow time.Time + +type ActualWorkRecords struct { + LineNumber int + Record string +} + +type WorkRecord struct { + LineNumber int + Date string + StartTime string + EndTime string + Issue string + Comment string + ParsedStartTime time.Time + ParsedEndTime time.Time + Duration int +} + +func getTimeLocation() *time.Location { + timezoneRaw, err := ioutil.ReadFile("/etc/timezone") + if err != nil { + clog.WithFields(log.Fields{"error": err}).Fatal("Failed to read '/etc/timezone'.") + } + timezone := strings.Trim(string(timezoneRaw), "\n") + clog.WithFields(log.Fields{"timezone": timezone}).Debug("Detected timezone.") + + location, err := time.LoadLocation(timezone) + if err != nil { + clog.WithFields(log.Fields{"error": err}).Fatal("Can't get time zone.") + } + + return location +} + +func getActualWorkRecords(timesheetCont []string) []ActualWorkRecords { + clog.Info("Looking for actual work records.") + var actualWorkRecords []ActualWorkRecords + + for i := len(timesheetCont) - 1; i >= 0; i-- { + wr := strings.TrimSpace(timesheetCont[i]) + if len(wr) == 0 || strings.HasPrefix(wr, "#") { + continue + } + + if wr == ">>> TIRED <<<" { + break + } + + awr := ActualWorkRecords{i, wr} + + clog.WithFields(log.Fields{"work record": awr}).Debug("New work record.") + actualWorkRecords = append(actualWorkRecords, awr) + } + + slices.Reverse(actualWorkRecords) + return actualWorkRecords +} + +func parseWorkRecords(records []ActualWorkRecords) ([]WorkRecord, int) { + var workRecords []WorkRecord + var errCount int + + timeNow = time.Now() + + clog.Info("Parsing actual work records.") + + // Location for time + location := getTimeLocation() + + var wrBefore WorkRecord + + for _, record := range records { + // Read original fileds + fields := strings.SplitN(record.Record, ",", 5) + + var wr WorkRecord + wr.LineNumber = record.LineNumber + wr.Date = fields[0] + wr.StartTime = fields[1] + wr.EndTime = fields[2] + wr.Issue = fields[3] + wr.Comment = strings.ReplaceAll(fields[4], "\"", "") + + // "00:00" is a begining of a day at go time parser. + if wr.EndTime == "00:00" { + wr.EndTime = "23:59" + } + + // Do not validate records with an empty EndTime as they haven't been compleated yet. + if wr.EndTime == "" { + clog.WithFields(log.Fields{ + "line": wr.LineNumber, + }).Warning("End time field is empty, skipping.") + + continue + } + + if !validateRawRecord(&wr) { + errCount += 1 + continue + } + + // Add duration + startDateTimeString := fmt.Sprintf("%s %s:00", wr.Date, wr.StartTime) + startDateTime, serr := time.ParseInLocation(time.DateTime, startDateTimeString, location) + if serr != nil { + clog.WithFields(log.Fields{"error": serr}).Fatal("Failed to parse start time.") + } + + // Required for record + wr.ParsedStartTime = startDateTime + + endDateTimeString := fmt.Sprintf("%s %s:00", wr.Date, wr.EndTime) + endDateTime, eerr := time.ParseInLocation(time.DateTime, endDateTimeString, location) + if eerr != nil { + clog.WithFields(log.Fields{ + "error": eerr, + }).Fatal("Failed to parse end time.") + } + + wr.ParsedEndTime = endDateTime + + wr.Duration = int(endDateTime.Sub(startDateTime).Seconds()) + + if !validateRecord(&wr, &wrBefore) { + errCount += 1 + continue + } + + clog.WithFields(log.Fields{"record": wr}).Debug("Record fields.") + + workRecords = append(workRecords, wr) + wrBefore = wr + } + + return workRecords, errCount +} + +func validateRawRecord(wr *WorkRecord) bool { + valid := true + + if wr.Date == "" || wr.StartTime == "" || wr.EndTime == "" || wr.Issue == "" || wr.Comment == "" { + clog.WithFields(log.Fields{ + "line": wr.LineNumber, + }).Error("Some fields are empty, only EndTime allowed.") + + valid = false + } + + matched, ierr := regexp.MatchString(`^[A-Z-]+-\d+$`, wr.Issue) + if ierr != nil || !matched { + clog.WithFields(log.Fields{ + "line": wr.LineNumber, + "issue": wr.Issue, + }).Error("Bad Jira issue number.") + + valid = false + } + + return valid +} + +func validateRecord(wr *WorkRecord, wrBefore *WorkRecord) bool { + valid := true + + if wr.Duration <= 0 { + clog.WithFields(log.Fields{ + "line": wr.LineNumber, + "duration": wr.Duration, + }).Error("Start time is after End time.") + + valid = false + } + + if wr.ParsedStartTime.Year() != timeNow.Year() { + clog.WithFields(log.Fields{ + "line": wr.LineNumber, + "year_record": wr.ParsedStartTime.Year(), + "year_current": timeNow.Year(), + }).Error("Record has date from another year.") + + valid = false + } + + if wr.ParsedStartTime.Before(wrBefore.ParsedEndTime) { + clog.WithFields(log.Fields{ + "line": wr.LineNumber, + "time_start": wr.ParsedStartTime, + "time_end": wrBefore.ParsedEndTime, + }).Error("Start time < than previous end time.") + + valid = false + } + + return valid +}