Skip to content

Commit

Permalink
feat(timewarrior): add initial timewarrior integration
Browse files Browse the repository at this point in the history
  • Loading branch information
gabor-boros committed Oct 13, 2021
1 parent 4fbb077 commit 748a304
Show file tree
Hide file tree
Showing 8 changed files with 667 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Expand Up @@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file.
- Add basic tempo client implementation ([202ac41](https://github.com/gabor-boros/minutes/commit/202ac41def09858d31809be3a6fa8cf5b9f95a00))
- Add basic clockify client implementation ([cb04282](https://github.com/gabor-boros/minutes/commit/cb04282b206bc1a926ab6e37b4cd67450e2c4766))
- Add initial CLI implementation ([98a6759](https://github.com/gabor-boros/minutes/commit/98a6759ec7557d5bdc5e313f00086cc468ee4197))
- Add initial timewarrior integration ([823c472](https://github.com/gabor-boros/minutes/commit/823c4720360850c1eaca6a6a7765e43c4a47877c))

**Miscellaneous Tasks**

Expand All @@ -34,6 +35,7 @@ All notable changes to this project will be documented in this file.
- Add issue templates ([99fba16](https://github.com/gabor-boros/minutes/commit/99fba16dc5a695d42d9dfee21fc7dad64ce98afe))
- Add virtualenv to gitignore ([466aa6d](https://github.com/gabor-boros/minutes/commit/466aa6d7d3cba1aba26185873c606d16c3e59483))
- Update changelog ([97d9867](https://github.com/gabor-boros/minutes/commit/97d986761306a892d1354228c650615a7146dfba))
- Use commit links in changelog ([8011d6a](https://github.com/gabor-boros/minutes/commit/8011d6af1d1e2ae917da871b16109991e3118812))

**Refactor**

Expand All @@ -46,6 +48,8 @@ All notable changes to this project will be documented in this file.
- Rename ci.yml to build.yml ([4165ea4](https://github.com/gabor-boros/minutes/commit/4165ea4eddf529563c4b8b54ea914a71c53d5ff9))
- Rename codeql-analysis.yml to codeql.yml ([88edae1](https://github.com/gabor-boros/minutes/commit/88edae1c0741141b5750ba79ca14bbdbe7741976))
- Remove unused `verbose` flag ([96c1e83](https://github.com/gabor-boros/minutes/commit/96c1e83bf70dfe62152d4ece1f61351e05834df5))
- Do not return pointer slice when splitting ([6a34847](https://github.com/gabor-boros/minutes/commit/6a34847c150815c25c04077daa557ea5855bf3ae))
- Add entry duration splitting as a method ([e657956](https://github.com/gabor-boros/minutes/commit/e657956f78e3fe37be22e3dfbb5dc65a6d345865))

**Testing**

Expand Down
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -192,7 +192,7 @@ widthmax = 40
| Tempo | **yes** | **yes** |
| Time Doctor | upon request | upon request |
| TimeCamp | upon request | upon request |
| Timewarrior | upon request | upon request |
| Timewarrior | **yes** | upon request |
| Toggl Track | **planned** | upon request |
| Zoho Books | upon request | **planned** |

Expand Down
49 changes: 48 additions & 1 deletion cmd/root.go
Expand Up @@ -7,10 +7,13 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"regexp"
"strings"
"time"

"github.com/gabor-boros/minutes/internal/pkg/client/timewarrior"

"github.com/gabor-boros/minutes/internal/cmd/printer"
"github.com/gabor-boros/minutes/internal/cmd/utils"
"github.com/gabor-boros/minutes/internal/pkg/client/clockify"
Expand All @@ -37,7 +40,7 @@ var (
commit string
date string

sources = []string{"clockify", "tempo"}
sources = []string{"clockify", "tempo", "timewarrior"}
targets = []string{"tempo"}

ErrNoSourceImplementation = errors.New("no source implementation found")
Expand Down Expand Up @@ -72,6 +75,7 @@ func init() {
initCommonFlags()
initClockifyFlags()
initTempoFlags()
initTimewarriorFlags()
}

func initConfig() {
Expand Down Expand Up @@ -141,6 +145,15 @@ func initTempoFlags() {
rootCmd.Flags().StringP("tempo-password", "", "", "set the login password")
}

func initTimewarriorFlags() {
rootCmd.Flags().StringP("timewarrior-command", "", "timew", "set the executable name")
rootCmd.Flags().StringSliceP("timewarrior-arguments", "", []string{}, "set additional arguments")

rootCmd.Flags().StringP("timewarrior-unbillable-tag", "", "unbillable", "set the unbillable tag")
rootCmd.Flags().StringP("timewarrior-client-tag-regex", "", "", "regex of client tag pattern")
rootCmd.Flags().StringP("timewarrior-project-tag-regex", "", "", "regex of project tag pattern")
}

func validateFlags() {
source := viper.GetString("source")
target := viper.GetString("target")
Expand Down Expand Up @@ -185,6 +198,25 @@ func validateFlags() {
cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the hideable columns %v\n", column, printer.HideableColumns))
}
}

switch source {
case "timewarrior":
if viper.GetString("timewarrior-command") == "" {
cobra.CheckErr("timewarrior command must be set")
}

if viper.GetString("timewarrior-unbillable-tag") == "" {
cobra.CheckErr("timewarrior unbillable tag must be set")
}

if viper.GetString("timewarrior-client-tag-regex") == "" {
cobra.CheckErr("timewarrior client tag regex must be set")
}

if viper.GetString("timewarrior-project-tag-regex") == "" {
cobra.CheckErr("timewarrior project tag regex must be set")
}
}
}

func getClientOpts(urlFlag string, usernameFlag string, passwordFlag string, tokenFlag string, tokenHeader string) (*client.BaseClientOpts, error) {
Expand Down Expand Up @@ -254,6 +286,21 @@ func getFetcher() (client.Fetcher, error) {
return tempo.NewClient(&tempo.ClientOpts{
BaseClientOpts: *opts,
}), nil
case "timewarrior":
opts := &client.BaseClientOpts{
TagsAsTasks: viper.GetBool("tags-as-tasks"),
TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"),
}

return timewarrior.NewClient(&timewarrior.ClientOpts{
BaseClientOpts: *opts,
Command: viper.GetString("timewarrior-command"),
CommandArguments: viper.GetStringSlice("timewarrior-arguments"),
CommandCtxExecutor: exec.CommandContext,
UnbillableTag: viper.GetString("timewarrior-unbillable-tag"),
ClientTagRegex: viper.GetString("timewarrior-client-tag-regex"),
ProjectTagRegex: viper.GetString("timewarrior-project-tag-regex"),
}), nil
default:
return nil, ErrNoSourceImplementation
}
Expand Down
211 changes: 211 additions & 0 deletions internal/pkg/client/timewarrior/timewarrior.go
@@ -0,0 +1,211 @@
package timewarrior

import (
"context"
"encoding/json"
"fmt"
"os/exec"
"regexp"
"time"

"github.com/gabor-boros/minutes/internal/pkg/client"
"github.com/gabor-boros/minutes/internal/pkg/worklog"
)

const (
dateFormat string = "2006-01-02T15:04:05"
ParseDateFormat string = "20060102T150405Z"
)

// FetchEntry represents the entry exported from Timewarrior.
type FetchEntry struct {
ID int `json:"id"`
Start string `json:"start"`
End string `json:"end"`
Tags []string `json:"tags"`
Annotation string `json:"annotation"`
}

// ClientOpts is the client specific options, extending client.BaseClientOpts.
// Since Timewarrior is a CLI tool, hence it has no API we could call on HTTP.
// Although client.HTTPClientOptions is part of client.BaseClientOpts, we are
// not using that as part of this integration, instead we are defining the path
// of the executable (Command) and the command arguments used for export
// (CommandArguments).
type ClientOpts struct {
client.BaseClientOpts
Command string
CommandArguments []string
CommandCtxExecutor func(ctx context.Context, name string, arg ...string) *exec.Cmd
UnbillableTag string
ClientTagRegex string
ProjectTagRegex string
}

type timewarriorClient struct {
opts *ClientOpts
}

func (c *timewarriorClient) assembleCommand(subcommand string, opts *client.FetchOpts) (string, []string) {
arguments := []string{subcommand}

arguments = append(
arguments,
[]string{
"from", opts.Start.Format(dateFormat),
"to", opts.End.Format(dateFormat),
}...,
)

arguments = append(arguments, c.opts.CommandArguments...)

return c.opts.Command, arguments
}

func (c *timewarriorClient) splitEntry(entry worklog.Entry, fetchEntry FetchEntry) ([]worklog.Entry, error) {
r, err := regexp.Compile(c.opts.TagsAsTasksRegex)
if err != nil {
return nil, err
}

tasks := map[string]string{}
for _, tag := range fetchEntry.Tags {
if task := r.FindString(tag); task != "" {
tasks[tag] = task
}
}

var entries []worklog.Entry
totalTasks := len(tasks)

for taskID, taskName := range tasks {
splitBillable, splitUnbillable := entry.SplitDuration(totalTasks)

entries = append(entries, worklog.Entry{
Client: entry.Client,
Project: entry.Project,
Task: worklog.IDNameField{
ID: taskID,
Name: taskName,
},
Summary: entry.Summary,
Notes: entry.Notes,
Start: entry.Start,
BillableDuration: splitBillable,
UnbillableDuration: splitUnbillable,
})
}

return entries, nil
}

func (c *timewarriorClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) {
var entries []worklog.Entry
var fetchedEntries []FetchEntry

command, arguments := c.assembleCommand("export", opts)

out, err := c.opts.CommandCtxExecutor(ctx, command, arguments...).Output() // #nosec G204
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

if err = json.Unmarshal(out, &fetchedEntries); err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

clientTagRegex, err := regexp.Compile(c.opts.ClientTagRegex)
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

projectTagRegex, err := regexp.Compile(c.opts.ProjectTagRegex)
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

tagsAsTasksRegex, err := regexp.Compile(c.opts.TagsAsTasksRegex)
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

for _, entry := range fetchedEntries {
var clientName string
var projectName string
var task worklog.IDNameField

startDate, err := time.ParseInLocation(ParseDateFormat, entry.Start, time.Local)
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

endDate, err := time.ParseInLocation(ParseDateFormat, entry.End, time.Local)
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

billableDuration := endDate.Sub(startDate)
unbillableDuration := time.Duration(0)

for _, tag := range entry.Tags {
if tag == c.opts.UnbillableTag {
unbillableDuration = billableDuration
billableDuration = 0
} else if c.opts.ClientTagRegex != "" && clientTagRegex.MatchString(tag) {
clientName = tag
} else if c.opts.ProjectTagRegex != "" && projectTagRegex.MatchString(tag) {
projectName = tag
} else if c.opts.TagsAsTasksRegex != "" && tagsAsTasksRegex.MatchString(tag) {
task = worklog.IDNameField{
ID: tag,
Name: tag,
}
}
}

// If the task was not found in tags, make sure to set it to annotation
if !task.IsComplete() {
task = worklog.IDNameField{
ID: entry.Annotation,
Name: entry.Annotation,
}
}

worklogEntry := worklog.Entry{
Client: worklog.IDNameField{
ID: clientName,
Name: clientName,
},
Project: worklog.IDNameField{
ID: projectName,
Name: projectName,
},
Task: task,
Summary: entry.Annotation,
Notes: entry.Annotation,
Start: startDate,
BillableDuration: billableDuration,
UnbillableDuration: unbillableDuration,
}

if c.opts.TagsAsTasks && len(entry.Tags) > 0 {
splitEntries, err := c.splitEntry(worklogEntry, entry)
if err != nil {
return entries, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

entries = append(entries, splitEntries...)
} else {
entries = append(entries, worklogEntry)
}
}

return entries, nil
}

// NewClient returns a new Timewarrior client.
func NewClient(opts *ClientOpts) client.Fetcher {
return &timewarriorClient{
opts: opts,
}
}

0 comments on commit 748a304

Please sign in to comment.