Skip to content

Commit

Permalink
feat: add Harvest as source (#33)
Browse files Browse the repository at this point in the history
* refactor(toggl): remove userID parsing and manipulation
* refactor: add IntIDNameField and conversion to IDNameField
* refactor: add DateFormat parsing
* chore(dependencies): update go.sum
* feat(harvest): add initial Harvest implementation
* ci(boring-cyborg): add harvest to labeler
  • Loading branch information
gabor-boros authored Nov 2, 2021
1 parent afc16c1 commit c949a0c
Show file tree
Hide file tree
Showing 13 changed files with 523 additions and 18 deletions.
3 changes: 3 additions & 0 deletions .github/boring-cyborg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ labelPRBasedOnFilePath:
clockify:
- internal/pkg/client/clockify/**/*

harvest:
- internal/pkg/client/harvest/**/*

tempo:
- internal/pkg/client/tempo/**/*

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ Flags:
--filter-client string filter for client name after fetching
--filter-project string filter for project name after fetching
--force-billed-duration treat every second spent as billed
--harvest-account int set the Account ID
--harvest-api-key string set the API key
-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]
-s, --source string set the source of the sync [clockify harvest 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]
Expand All @@ -86,7 +88,6 @@ Flags:
--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
```
Expand Down Expand Up @@ -182,7 +183,7 @@ widthmax = 40
| Clockify | **yes** | upon request |
| Everhour | upon request | upon request |
| FreshBooks | upon request | **planned** |
| Harvest | upon request | upon request |
| Harvest | **yes** | upon request |
| QuickBooks | upon request | upon request |
| Tempo | **yes** | **yes** |
| Time Doctor | upon request | upon request |
Expand Down
27 changes: 23 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ import (
"strings"
"time"

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

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

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

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

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

ErrNoSourceImplementation = errors.New("no source implementation found")
Expand Down Expand Up @@ -75,6 +74,7 @@ func init() {

initCommonFlags()
initClockifyFlags()
initHarvestFlags()
initTempoFlags()
initTimewarriorFlags()
initTogglFlags()
Expand Down Expand Up @@ -144,6 +144,11 @@ func initClockifyFlags() {
rootCmd.Flags().StringP("clockify-workspace", "", "", "set the workspace ID")
}

func initHarvestFlags() {
rootCmd.Flags().StringP("harvest-api-key", "", "", "set the API key")
rootCmd.Flags().IntP("harvest-account", "", 0, "set the Account ID")
}

func initTempoFlags() {
rootCmd.Flags().StringP("tempo-url", "", "", "set the base URL")
rootCmd.Flags().StringP("tempo-username", "", "", "set the login user ID")
Expand Down Expand Up @@ -257,6 +262,20 @@ func getFetcher() (client.Fetcher, error) {
BaseURL: viper.GetString("clockify-url"),
Workspace: viper.GetString("clockify-workspace"),
})
case "harvest":
return harvest.NewFetcher(&harvest.ClientOpts{
BaseClientOpts: client.BaseClientOpts{
TagsAsTasks: viper.GetBool("tags-as-tasks"),
TagsAsTasksRegex: tagsAsTasksRegex,
Timeout: client.DefaultRequestTimeout,
},
TokenAuth: client.TokenAuth{
TokenName: "Bearer",
Token: viper.GetString("harvest-api-key"),
},
BaseURL: "https://api.harvestapp.com",
Account: viper.GetInt("harvest-account"),
})
case "tempo":
return tempo.NewFetcher(&tempo.ClientOpts{
BaseClientOpts: client.BaseClientOpts{
Expand Down
3 changes: 0 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
Expand Down Expand Up @@ -471,7 +470,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf h1:2ucpDCmfkl8Bd/FsLtiD653Wf96cW37s+iGx93zsu4k=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk=
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand All @@ -483,7 +481,6 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
Expand Down
194 changes: 194 additions & 0 deletions internal/pkg/client/harvest/harvest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package harvest

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"

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

const (
// PathWorklog is the endpoint used to search existing worklogs.
PathWorklog string = "/v2/time_entries"
)

// FetchEntry represents the entry fetched from Harvest.
type FetchEntry struct {
Client worklog.IntIDNameField `json:"client"`
Project worklog.IntIDNameField `json:"project"`
Task worklog.IntIDNameField `json:"task"`
Notes string `json:"notes"`
SpentDate string `json:"spent_date"`
Hours float32 `json:"hours"`
CreatedAt time.Time `json:"created_at"`
Billable bool `json:"billable"`
IsRunning bool `json:"is_running"`
}

// Start returns the start date created from the spent date and created at.
// The spent date represents the date the user wants the entry to be logged,
// e.g: 2021-10-01. The creation date represents the actual creation of the
// entry, e.g: 2021-10-02T10:26:20Z. Since Harvest is not precise with the
// spent date, we have to create a start date from these two entries. This is
// needed, because if the user is manually creating an entry, and creates on
// a wrong date accidentally, after editing the entry, the spent date will be
// updated, though the creation date not.
func (e *FetchEntry) Start() (time.Time, error) {
spentDate, err := utils.DateFormatISO8601.Parse(e.SpentDate)
if err != nil {
return time.Time{}, err
}

return time.Date(
spentDate.Year(),
spentDate.Month(),
spentDate.Day(),
e.CreatedAt.Hour(),
e.CreatedAt.Minute(),
e.CreatedAt.Second(),
e.CreatedAt.Nanosecond(),
e.CreatedAt.Location(),
), nil
}

// FetchResponse represents the relevant response data.
// Although the response contains a lot more information about pagination, it
// cannot be used with the current structure.
type FetchResponse struct {
TimeEntries []FetchEntry `json:"time_entries"`
PerPage int `json:"per_page"`
TotalEntries int `json:"total_entries"`
}

// ClientOpts is the client specific options, extending client.BaseClientOpts.
type ClientOpts struct {
client.BaseClientOpts
client.TokenAuth
BaseURL string
Account int
}

type harvestClient struct {
*client.BaseClientOpts
*client.HTTPClient
authenticator client.Authenticator
account int
}

func (c *harvestClient) parseEntries(rawEntries interface{}) (worklog.Entries, error) {
var entries worklog.Entries

fetchedEntries, ok := rawEntries.([]FetchEntry)
if !ok {
return nil, fmt.Errorf("%v: %s", client.ErrFetchEntries, "cannot parse returned entries")
}

for _, fetchedEntry := range fetchedEntries {
startDate, err := fetchedEntry.Start()
if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

billableDuration, err := time.ParseDuration(fmt.Sprintf("%fh", fetchedEntry.Hours))
if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

unbillableDuration := time.Duration(0)

if !fetchedEntry.Billable {
unbillableDuration = billableDuration
billableDuration = 0
}

entries = append(entries, worklog.Entry{
Client: fetchedEntry.Client.ConvertToIDNameField(),
Project: fetchedEntry.Project.ConvertToIDNameField(),
Task: fetchedEntry.Task.ConvertToIDNameField(),
Summary: fetchedEntry.Notes,
Notes: fetchedEntry.Notes,
Start: startDate,
BillableDuration: billableDuration,
UnbillableDuration: unbillableDuration,
})
}

return entries, nil
}

func (c *harvestClient) fetchEntries(ctx context.Context, reqURL string) (interface{}, *client.PaginatedFetchResponse, error) {
resp, err := c.Call(ctx, &client.HTTPRequestOpts{
Method: http.MethodGet,
Url: reqURL,
Auth: c.authenticator,
Timeout: c.Timeout,
Headers: map[string]string{
"Harvest-Account-ID": strconv.Itoa(c.account),
},
})

if err != nil {
return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

var fetchResponse FetchResponse
if err = json.Unmarshal(resp, &fetchResponse); err != nil {
return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

paginatedResponse := &client.PaginatedFetchResponse{
EntriesPerPage: fetchResponse.PerPage,
TotalEntries: fetchResponse.TotalEntries,
}

return fetchResponse.TimeEntries, paginatedResponse, err
}

func (c *harvestClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) {
fetchURL, err := c.URL(PathWorklog, map[string]string{
"from": utils.DateFormatRFC3339UTC.Format(opts.Start),
"to": utils.DateFormatRFC3339UTC.Format(opts.End),
"user_id": opts.User,
"is_running": strconv.FormatBool(false),
"user_agent": "github.com/gabor-boros/minutes",
})

if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

return c.PaginatedFetch(ctx, &client.PaginatedFetchOpts{
URL: fetchURL,
FetchFunc: c.fetchEntries,
ParseFunc: c.parseEntries,
})
}

// NewFetcher returns a new Clockify client for fetching entries.
func NewFetcher(opts *ClientOpts) (client.Fetcher, error) {
baseURL, err := url.Parse(opts.BaseURL)
if err != nil {
return nil, err
}

authenticator, err := client.NewTokenAuth(opts.Header, opts.TokenName, opts.Token)
if err != nil {
return nil, err
}

return &harvestClient{
BaseClientOpts: &opts.BaseClientOpts,
HTTPClient: &client.HTTPClient{
BaseURL: baseURL,
},
authenticator: authenticator,
account: opts.Account,
}, nil
}
Loading

0 comments on commit c949a0c

Please sign in to comment.