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 Harvest as source #33

Merged
merged 6 commits into from
Nov 2, 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
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