diff --git a/README.md b/README.md index 154a9d3..134978c 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,32 @@ given the user knows the issue reference ## Usage ``` -This tool can be used to log time spent on a specific Jira ticket on a project. +timesheet (-r -t [-d] [-m]] [[-h] [-e] [-d]) (-remaining [-history]) -d string - OPTIONAL: The date on which the worklog effort was started in YYYY-MM-DD format. Default 2020-03-06 + OPTIONAL: The date on which the worklog effort was started in YYYY-MM-DD format. Default 2020-03-06 -e string - HELP: Base64 encode the given credentials. Format: email:token;domain. e.g. example@example.com:abcThisIsFake;xyz.atlassian.net - -h HELP: Print usage + HELP: Base64 encode the given credentials. Format: email:token;domain. e.g. example@example.com:abcThisIsFake;xyz.atlassian.net + -h HELP: This tool can be used to log time spent on a specific Jira ticket on a project. -history - HELP: Print the timesheet of the day + HELP: Print the timesheet of the day -m string - OPTIONAL: A comment about the worklog + OPTIONAL: A comment about the worklog -r string - REQUIRED: Jira ticket reference. E.g. DDSP-4 + REQUIRED: Jira ticket reference. E.g. DDSP-4 -remaining - HELP: Print how many hour can be book for the current day. -history and -d are also available + HELP: Print how many hour can be book for the current day. -history and -d are also available -t string - REQUIRED: The time spent as days (#d), hours (#h), or minutes (#m or #). E.g. 8h + REQUIRED: The time spent as days (#d), hours (#h), or minutes (#m or #). E.g. 8h + -week + HELP: Print timesheet of the current week + +Example: + timesheet -r DDSP-XXXX -t 8h -m "Jenkins pipeline completed" + timesheet -r DDSP-XXXX -t 1h -m "Investigated possible solutions" -d 2020-03-05 + timesheet -remaining + timesheet -remaining -d 2020-03-05 + timesheet -remaining -history + timesheet -remaining -history -d 2020-03-05 ``` ## Requirements diff --git a/argparse.go b/argparse.go index 83466fa..4a4e309 100644 --- a/argparse.go +++ b/argparse.go @@ -20,7 +20,7 @@ var dateFormat, _ = regexp.Compile("[0-9]{4}-[0-9]{2}-[0-9]{2}") func (app *App) Parser() { app.Started = app.getDateTime() - flag.BoolVar(&app.Help, "h", false, "HELP: Print usage") + flag.BoolVar(&app.Help, "h", false, "HELP: This tool can be used to log time spent on a specific Jira ticket on a project.") flag.StringVar(&app.Ticket, "r", "", "REQUIRED: Jira ticket reference. E.g. DDSP-4") flag.StringVar(&app.TimeSpent, "t", "", @@ -31,21 +31,36 @@ func (app *App) Parser() { "OPTIONAL: A comment about the worklog") flag.StringVar(&app.Encode, "e", "", "HELP: Base64 encode the given credentials."+ " Format: email:token;domain. e.g. example@example.com:abcThisIsFake;xyz.atlassian.net") - flag.BoolVar(&app.TimeRemaining, "remaining", false, "HELP: Print how many hour can be book for the current day." + + flag.BoolVar(&app.TimeRemaining, "remaining", false, "HELP: Print how many hour can be book for the current day."+ " -history and -d are also available") flag.BoolVar(&app.History, "history", false, "HELP: Print the timesheet of the day") + flag.BoolVar(&app.PrintWeek, "week", false, "HELP: Print timesheet of the current week") flag.Parse() app.validate() } func (app *App) validate() { + + if len(os.Args[1:]) < 1 { + fmt.Printf("no arguments are given\n\n") + app.usage() + } + + if app.Started != "" { + if !dateFormat.MatchString(app.Started) { + panic("provided date didn't match expected format. try -h for help") + } else { + app.Started = fmt.Sprintf("%sT%s", app.Started, app.getTimeFixed()) + } + } else { + app.Started = app.getDateTime() + } + if app.Help { - fmt.Println("This tool can be used to log time spent on a specific Jira ticket on a project.") app.usage() - os.Exit(0) } - if app.TimeRemaining { + if app.TimeRemaining || app.PrintWeek { return } @@ -61,18 +76,17 @@ func (app *App) validate() { if app.TimeSpent == "" { panic(errors.New("no time given. -t")) } - - if app.Started != "" { - if !dateFormat.MatchString(app.Started) { - panic("provided date didn't match expected format. try -h for help") - } else { - app.Started = fmt.Sprintf("%sT%s", app.Started, app.getTimeFixed()) - } - } else { - app.Started = app.getDateTime() - } } func (app *App) usage() { + fmt.Printf("timesheet (-r -t [-d] [-m]] [[-h] [-e] [-d]) (-remaining [-history])\n") flag.PrintDefaults() + fmt.Printf("Example:\n" + + "\ttimesheet -r DDSP-XXXX -t 8h -m \"Jenkins pipeline completed\"\n" + + "\ttimesheet -r DDSP-XXXX -t 1h -m \"Investigated possible solutions\" -d 2020-03-05\n" + + "\ttimesheet -remaining\n" + + "\ttimesheet -remaining -d 2020-03-05\n" + + "\ttimesheet -remaining -history\n" + + "\ttimesheet -remaining -history -d 2020-03-05\n") + os.Exit(1) } diff --git a/atlassian.go b/atlassian.go index e1ff779..cfced79 100644 --- a/atlassian.go +++ b/atlassian.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net/http" "strings" ) @@ -69,6 +70,29 @@ type Worklog struct { } `json:"author"` } +type WeekLog struct { + Total int + Issues []Issue +} + +type Issue struct { + Key string + Logs []DayLog +} + +type DayLog struct { + Total int + WeekDay string + TimeSpent int +} + +type Week struct { + Total int + Days map[string]map[string][]int +} + +var daysOfWeek = []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday"} + func LogTime(reference string, time string, started string, comment string, domain string, auth string) { var slot = TimeLog{} slot.TimeSpent = time @@ -142,6 +166,45 @@ func (app *App) GetTimeRemaining(domain string, auth string) { } +func (app *App) GetWeekTimesheet(domain string, auth string) { + var weekLog WeekLog + start, end := app.getWeek() + userEmail, _ := basicAuth(auth) + issuesOfTheWeek, iErr := getIssuesUpdatedBetweenDays(domain, auth, + start.Format("2006-01-02"), end.Format("2006-01-02")) + if iErr != nil { + panic(iErr) + } + + worklogs, wErr := issuesOfTheWeek.getWorklogs(domain, auth) + if wErr != nil { + panic(wErr) + } + + for _, wLog := range worklogs { + var issue Issue + issue.Key = wLog.Key + if wLog.Total > 0 { + for _, log := range wLog.Worklogs { + if app.isDateBetween(log.Started, start, end) { + if log.Author.EmailAddress == userEmail { + var dayLog DayLog + dayLog.WeekDay = getDateOfWeek(log.Started) + dayLog.TimeSpent = log.TimeSpentSeconds + weekLog.Total += log.TimeSpentSeconds + issue.Logs = append(issue.Logs, dayLog) + } + } + } + } + if len(issue.Logs) > 0 { + weekLog.Issues = append(weekLog.Issues, issue) + } + } + + weekLog.print() +} + func getIssuesUpdatedToday(domain string, auth string, date string) (*JiraSearchResult, error) { var client = &http.Client{} var query = fmt.Sprintf("jql=worklogDate%%20>%%3D%%20\"%s\"%%20AND%%20worklogDate%%20<%%3D%%20\"%s\"", date, date) @@ -169,6 +232,28 @@ func getIssuesUpdatedToday(domain string, auth string, date string) (*JiraSearch return response, nil } +func getIssuesUpdatedBetweenDays(domain string, auth string, start string, end string) (*JiraSearchResult, error) { + var query = fmt.Sprintf("jql=worklogDate%%20>%%3D%%20\"%s\"%%20AND%%20worklogDate%%20<%%3D%%20\"%s\"", start, end) + var url = fmt.Sprintf("https://%s/rest/api/3/search?%s", strings.TrimSuffix(domain, "\n"), query) + + var headers []struct { + key string + value string + } + + headers = append(headers, struct { + key string + value string + }{key: "Content-Type", value: "application/json"}) + + var response = new(JiraSearchResult) + decodeErr := json.NewDecoder(httpReq("GET", url, auth, nil, headers).Body).Decode(&response) + if decodeErr != nil { + panic(decodeErr) + } + return response, nil +} + func (issues *JiraSearchResult) getWorklogs(domain string, auth string) ([]WorkLogs, error) { var worklogs []WorkLogs for _, issue := range issues.Issues { @@ -248,6 +333,140 @@ func basicAuth(token string) (string, string) { return loginDetails[0], loginDetails[1] } -func getInHours(seconds int) float64 { - return float64(seconds) / float64(3600) +func httpReq(method string, url string, auth string, body io.Reader, headers []struct { + key string + value string +}) *http.Response { + var client = &http.Client{} + req, reqErr := http.NewRequest(method, url, body) + if reqErr != nil { + panic(reqErr) + } + + for _, header := range headers { + req.Header.Add(header.key, header.value) + } + req.SetBasicAuth(basicAuth(auth)) + resp, err := client.Do(req) + if err != nil { + panic(err) + } + + //defer resp.Body.Close() + return resp + +} + +func (w *WeekLog) sort() Week { + var sortedWeek Week + + sortedWeek.Days = make(map[string]map[string][]int) + + sortedWeek.Total = w.Total + for _, issue := range w.Issues { + sortedWeek.Days[issue.Key] = make(map[string][]int) + for _, day := range issue.Logs { + sortedWeek.Days[issue.Key][day.WeekDay] = append(sortedWeek.Days[issue.Key][day.WeekDay], day.TimeSpent) + } + } + + return sortedWeek.fillGaps() +} + +func (w *Week) fillGaps() Week { + for _, days := range w.Days { + for _, d := range daysOfWeek { + if _, found := days[d]; !found { + days[d] = []int{0} + } + } + } + + return *w +} + +func (w *Week) sum() Week { + for _, days := range w.Days { + for _, d := range daysOfWeek { + if times, found := days[d]; !found { + sum := 0 + for _, time := range times { + sum += time + } + days[d] = []int{sum} + } + } + } + + return *w +} + +func (w *WeekLog) print() { + for i := 0; i <= 83; i++ { + if i == 0 { + fmt.Printf(" ") + } else if i == 83 { + fmt.Printf("\n") + } else { + fmt.Printf("_") + } + } + + fmt.Printf("| %-15s ", "Issue") + for _, title := range daysOfWeek { + fmt.Printf("| %-10s ", title) + } + fmt.Printf("|\n") + for i := 0; i <= 82; i++ { + if i == 0 { + fmt.Printf("|") + } else if i == 18 || i == 31 || i == 44 || i == 57 || i == 70 { + fmt.Printf("|") + } else if i == 82 { + fmt.Printf("_|\n") + } else { + fmt.Printf("_") + } + } + + weekSorted := w.sort() + var processedIssues int + + for issue, day := range weekSorted.sum().Days { + if processedIssues > 0 { + for i := 0; i <= 83; i++ { + if i == 0 { + fmt.Printf("|") + } else if i == 83 { + fmt.Printf("|\n") + } else if i == 18 || i == 31 || i == 44 || i == 57 || i == 70 { + fmt.Printf("|") + } else { + fmt.Printf("-") + } + } + } + fmt.Printf("| %-15s ", issue) + for _, v := range daysOfWeek { + if day[v][0] == 0 { + fmt.Printf("| %-10s ", "") + } else { + fmt.Printf("| %-10.1f ", getInHours(day[v][0])) + } + } + fmt.Println("|") + processedIssues += 1 + } + + for i := 0; i <= 83; i++ { + if i == 0 { + fmt.Printf(" ") + } else if i == 83 { + fmt.Printf(" \n") + } else { + fmt.Printf("-") + } + } + + fmt.Println(fmt.Sprintf("Total %.1fh", getInHours(weekSorted.Total))) } diff --git a/datetime.go b/datetime.go index f7f6ad9..3e7f269 100644 --- a/datetime.go +++ b/datetime.go @@ -3,6 +3,7 @@ package main import ( "fmt" "regexp" + "strconv" "strings" "time" ) @@ -44,3 +45,57 @@ func (app *App) isDateMatch(datetime string) bool { } return false } + +func (app *App) isDateBetween(datetime string, start time.Time, end time.Time) bool { + dateExpr := regexp.MustCompile(`([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})`) + timeArray := dateExpr.FindAllStringSubmatch(datetime, -1) + year, month, day, hour, minute, second := timeArray[0][1], timeArray[0][2], timeArray[0][3], timeArray[0][4], timeArray[0][5], timeArray[0][6] + + date := time.Date(toInt(year), time.Month(toInt(month)), toInt(day), toInt(hour), toInt(minute), toInt(second), 0, start.Location()) + + var normalizedStartDate, _ = fullDay(start) + var _, normalizedEndDate = fullDay(end) + + if date.After(normalizedStartDate) && date.Before(normalizedEndDate) { + return true + } + return false +} + +func (app *App) getWeek() (time.Time, time.Time) { + var now = time.Now() + var weekBegin time.Time + var weekEnd time.Time + var dayPos = int(now.Weekday()) + var spanLeft = 1 - dayPos + var spanRight = 5 - dayPos + weekBegin = now.AddDate(0, 0, spanLeft) + weekEnd = now.AddDate(0, 0, spanRight) + return weekBegin, weekEnd +} + +func fullDay(date time.Time) (time.Time, time.Time) { + yeah, month, day := date.Date() + return time.Date(yeah, month, day, 0, 0, 0, 0, date.Location()), time.Date(yeah, month, day, 23, 59, 59, 0, date.Location()) +} + +func getInHours(seconds int) float64 { + return float64(seconds) / float64(3600) +} + +func getDateOfWeek(datetime string) string { + dateExpr, _ := regexp.Compile("([0-9]{4}-[0-9]{2}-[0-9]{2})") + date, err := time.Parse("2006-01-02", dateExpr.FindString(datetime)) + if err != nil { + panic(err) + } + return date.Weekday().String() +} + +func toInt(s string) int { + value, err := strconv.ParseInt(s, 10, 64) + if err != nil { + panic(err) + } + return int(value) +} diff --git a/main.go b/main.go index 8f011ab..48ee610 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,7 @@ type App struct { Encode string TimeRemaining bool History bool + PrintWeek bool Configuration struct { Auth string Domain string @@ -64,6 +65,11 @@ func main() { os.Exit(0) } + if app.PrintWeek { + app.GetWeekTimesheet(app.Configuration.Domain, app.Configuration.Auth) + os.Exit(0) + } + LogTime(app.Ticket, app.TimeSpent, app.Started, app.Comment, app.Configuration.Domain, app.Configuration.Auth) app.TimeRemaining = true app.GetTimeRemaining(app.Configuration.Domain, app.Configuration.Auth)