Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add initial Toggl Track integration #13

Merged
merged 4 commits into from
Oct 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 32 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,37 @@ Usage:
minutes [flags]

Flags:
--clockify-api-key string set the API key
--clockify-url string set the base URL
--clockify-workspace string set the workspace ID
--config string config file (default is $HOME/.minutes.yaml)
--date-format string set start and end date format (in Go style) (default "2006-01-02 15:04:05")
--dry-run fetch entries, but do not sync them
--end string set the end date (defaults to now)
--force-billed-duration treat every second spent as billed
-h, --help help for minutes
--round-to-closest-minute round time to closest minute
-s, --source string set the source of the sync [clockify tempo]
--source-user string set the source user ID
--start string set the start date (defaults to 00:00:00)
--table-hide-column strings hide table column [summary project client start end]
--table-sort-by strings sort table by column [task summary project client start end billable unbillable] (default [start,project,task,summary])
-t, --target string set the target of the sync [tempo]
--target-user string set the source user ID
--tags-as-tasks treat tags matching the value of tags-as-tasks-regex as tasks
--tags-as-tasks-regex string regex of the task pattern
--tempo-password string set the login password
--tempo-url string set the base URL
--tempo-username string set the login user ID
--verbose print verbose messages
--version show command version
--clockify-api-key string set the API key
--clockify-url string set the base URL
--clockify-workspace string set the workspace ID
--config string config file (default is $HOME/.minutes.yaml)
--date-format string set start and end date format (in Go style) (default "2006-01-02 15:04:05")
--dry-run fetch entries, but do not sync them
--end string set the end date (defaults to now)
--force-billed-duration treat every second spent as billed
-h, --help help for minutes
--round-to-closest-minute round time to closest minute
-s, --source string set the source of the sync [clockify tempo timewarrior toggl]
--source-user string set the source user ID
--start string set the start date (defaults to 00:00:00)
--table-hide-column strings hide table column [summary project client start end]
--table-sort-by strings sort table by column [task summary project client start end billable unbillable] (default [start,project,task,summary])
--tags-as-tasks treat tags matching the value of tags-as-tasks-regex as tasks
--tags-as-tasks-regex string regex of the task pattern
-t, --target string set the target of the sync [tempo]
--target-user string set the source user ID
--tempo-password string set the login password
--tempo-url string set the base URL
--tempo-username string set the login user ID
--timewarrior-arguments strings set additional arguments
--timewarrior-client-tag-regex string regex of client tag pattern
--timewarrior-command string set the executable name (default "timew")
--timewarrior-project-tag-regex string regex of project tag pattern
--timewarrior-unbillable-tag string set the unbillable tag (default "unbillable")
--toggl-api-key string set the API key
--toggl-url string set the base URL (default "https://api.track.toggl.com")
--toggl-workspace int set the workspace ID
--version show command version
```

### Usage examples
Expand Down Expand Up @@ -179,7 +186,7 @@ widthmax = 40
| Time Doctor | upon request | upon request |
| TimeCamp | upon request | upon request |
| Timewarrior | **yes** | upon request |
| Toggl Track | **planned** | upon request |
| Toggl Track | **yes** | upon request |
| Zoho Books | upon request | **planned** |

See the [open issues](/~https://github.com/gabor-boros/minutes/issues) for a full list of proposed features, tools and known issues.
Expand Down
34 changes: 32 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"strings"
"time"

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

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

"github.com/gabor-boros/minutes/internal/cmd/utils"
Expand Down Expand Up @@ -40,7 +42,7 @@ var (
commit string
date string

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

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

func initConfig() {
Expand Down Expand Up @@ -134,7 +137,7 @@ func initCommonFlags() {
}

func initClockifyFlags() {
rootCmd.Flags().StringP("clockify-url", "", "", "set the base URL")
rootCmd.Flags().StringP("clockify-url", "", "https://api.clockify.me", "set the base URL")
rootCmd.Flags().StringP("clockify-api-key", "", "", "set the API key")
rootCmd.Flags().StringP("clockify-workspace", "", "", "set the workspace ID")
}
Expand All @@ -154,6 +157,12 @@ func initTimewarriorFlags() {
rootCmd.Flags().StringP("timewarrior-project-tag-regex", "", "", "regex of project tag pattern")
}

func initTogglFlags() {
rootCmd.Flags().StringP("toggl-url", "", "https://api.track.toggl.com", "set the base URL")
rootCmd.Flags().StringP("toggl-api-key", "", "", "set the API key")
rootCmd.Flags().IntP("toggl-workspace", "", 0, "set the workspace ID")
}

func validateFlags() {
source := viper.GetString("source")
target := viper.GetString("target")
Expand Down Expand Up @@ -301,6 +310,27 @@ func getFetcher() (client.Fetcher, error) {
ClientTagRegex: viper.GetString("timewarrior-client-tag-regex"),
ProjectTagRegex: viper.GetString("timewarrior-project-tag-regex"),
}), nil
case "toggl":
opts, err := getClientOpts(
"toggl-url",
"toggl-api-key",
"",
"",
"",
)

// Toggl requires basic auth with the token set as the username and
// "api_token" set for password as a fix value to access their APIs
opts.Password = "api_token"

if err != nil {
return nil, err
}

return toggl.NewClient(&toggl.ClientOpts{
BaseClientOpts: *opts,
Workspace: viper.GetInt("toggl-workspace"),
}), nil
default:
return nil, ErrNoSourceImplementation
}
Expand Down
3 changes: 2 additions & 1 deletion internal/pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -166,7 +167,7 @@ func SendRequest(ctx context.Context, method string, path string, data interface
return nil, err
}

return nil, errors.New(string(errBody))
return nil, fmt.Errorf("%d: %s", resp.StatusCode, string(errBody))
}

return resp, err
Expand Down
79 changes: 17 additions & 62 deletions internal/pkg/client/clockify/clockify.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,11 @@ const (

// Project represents the project assigned to an entry.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
worklog.IDNameField
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
}

// Tag represents a tag assigned to an entry.
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}

// Task represents the task assigned to an entry.
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
}

// Interval represents the Start and End date of an entry.
type Interval struct {
Start time.Time `json:"start"`
Expand All @@ -52,12 +39,12 @@ type Interval struct {

// FetchEntry represents the entry fetched from Clockify.
type FetchEntry struct {
Description string `json:"description"`
Billable bool `json:"billable"`
Project Project `json:"project"`
TimeInterval Interval `json:"timeInterval"`
Task Task `json:"task"`
Tags []Tag `json:"tags"`
Description string `json:"description"`
Billable bool `json:"billable"`
Project Project `json:"project"`
TimeInterval Interval `json:"timeInterval"`
Task worklog.IDNameField `json:"task"`
Tags []worklog.IDNameField `json:"tags"`
}

// WorklogSearchParams represents the parameters used to filter search results.
Expand Down Expand Up @@ -101,48 +88,20 @@ func (c *clockifyClient) getSearchURL(user string, params *WorklogSearchParams)
return fmt.Sprintf("%s?%s", worklogURL.Path, worklogURL.Query().Encode()), nil
}

func (c *clockifyClient) splitEntry(entry worklog.Entry, fetchedEntry FetchEntry) ([]worklog.Entry, error) {
r, err := regexp.Compile(c.opts.TagsAsTasksRegex)
if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

tasks := map[string]string{}
for _, tag := range fetchedEntry.Tags {
if task := r.FindString(tag.Name); task != "" {
tasks[tag.ID] = 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: fetchedEntry.Description,
Notes: fetchedEntry.Description,
Start: entry.Start,
BillableDuration: splitBillable,
UnbillableDuration: splitUnbillable,
})
}

return entries, nil
}

func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) {
var err error
var entries []worklog.Entry
currentPage := 1
pageSize := 100

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

// Naive pagination as the API does not return the number of total entries
for currentPage*pageSize < MaxPageLength {
searchParams := &WorklogSearchParams{
Expand Down Expand Up @@ -204,11 +163,7 @@ func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpt
}

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

pageEntries := worklogEntry.SplitByTagsAsTasks(entry.Description, tagsAsTasksRegex, entry.Tags)
entries = append(entries, pageEntries...)
} else {
entries = append(entries, worklogEntry)
Expand Down
40 changes: 24 additions & 16 deletions internal/pkg/client/clockify/clockify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,20 +115,22 @@ func TestClockifyClient_FetchEntries(t *testing.T) {
Description: "Have a coffee with Tony",
Billable: true,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{
Task: worklog.IDNameField{
ID: "789",
Name: "Meet with Iron Man",
},
Tags: []clockify.Tag{
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand All @@ -147,20 +149,22 @@ func TestClockifyClient_FetchEntries(t *testing.T) {
Description: "Go back for my wallet",
Billable: false,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{
Task: worklog.IDNameField{
ID: "789",
Name: "Meet with Iron Man",
},
Tags: []clockify.Tag{
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand Down Expand Up @@ -284,17 +288,19 @@ func TestClockifyClient_FetchEntries_TasksAsTags(t *testing.T) {
Description: "Have a coffee with Tony",
Billable: true,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{},
Tags: []clockify.Tag{
Task: worklog.IDNameField{},
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand All @@ -313,17 +319,19 @@ func TestClockifyClient_FetchEntries_TasksAsTags(t *testing.T) {
Description: "Go back for my wallet",
Billable: false,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{},
Tags: []clockify.Tag{
Task: worklog.IDNameField{},
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand Down
Loading