diff --git a/.github/boring-cyborg.yml b/.github/boring-cyborg.yml index b95677c..d49169c 100644 --- a/.github/boring-cyborg.yml +++ b/.github/boring-cyborg.yml @@ -8,6 +8,9 @@ labelPRBasedOnFilePath: clockify: - internal/pkg/client/clockify/**/* + harvest: + - internal/pkg/client/harvest/**/* + tempo: - internal/pkg/client/tempo/**/* diff --git a/README.md b/README.md index fcbc333..889e7e2 100644 --- a/README.md +++ b/README.md @@ -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] @@ -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 ``` @@ -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 | diff --git a/cmd/root.go b/cmd/root.go index 22b3395..00516d4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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") @@ -75,6 +74,7 @@ func init() { initCommonFlags() initClockifyFlags() + initHarvestFlags() initTempoFlags() initTimewarriorFlags() initTogglFlags() @@ -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") @@ -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{ diff --git a/go.sum b/go.sum index 77c4dff..a31cf83 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= diff --git a/internal/pkg/client/harvest/harvest.go b/internal/pkg/client/harvest/harvest.go new file mode 100644 index 0000000..0737802 --- /dev/null +++ b/internal/pkg/client/harvest/harvest.go @@ -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 +} diff --git a/internal/pkg/client/harvest/harvest_test.go b/internal/pkg/client/harvest/harvest_test.go new file mode 100644 index 0000000..607ecb2 --- /dev/null +++ b/internal/pkg/client/harvest/harvest_test.go @@ -0,0 +1,201 @@ +package harvest_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gabor-boros/minutes/internal/pkg/client" + "github.com/gabor-boros/minutes/internal/pkg/client/harvest" + "github.com/gabor-boros/minutes/internal/pkg/utils" + "github.com/gabor-boros/minutes/internal/pkg/worklog" + + "github.com/stretchr/testify/require" +) + +type mockServerOpts struct { + Path string + QueryParams url.Values + Method string + StatusCode int + Token string + TokenHeader string + ResponseData *harvest.FetchResponse +} + +func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, e.Method, r.Method, "API call methods are not matching") + require.Equal(t, e.Path, r.URL.Path, "API call URLs are not matching") + require.Equal(t, e.QueryParams, r.URL.Query()) + + if e.Token != "" { + headerValue := r.Header.Get(e.TokenHeader) + require.Equal(t, e.Token, headerValue, "API call auth token mismatch") + } + + if e.ResponseData != nil { + err := json.NewEncoder(w).Encode(e.ResponseData) + require.Nil(t, err, "cannot encode response data") + } + + w.WriteHeader(e.StatusCode) + })) +} + +func newMockServer(t *testing.T, opts *mockServerOpts) *httptest.Server { + mockServer := mockServer(t, opts) + require.NotNil(t, mockServer, "cannot create mock server") + return mockServer +} + +func TestFetchEntry_Start(t *testing.T) { + expectedStart := time.Date(2021, 9, 30, 23, 59, 59, 0, time.UTC) + + entry := harvest.FetchEntry{ + SpentDate: "2021-09-30", + CreatedAt: time.Date(2021, 10, 1, 23, 59, 59, 0, time.UTC), + } + + startDate, err := entry.Start() + + require.Nil(t, err) + require.Equal(t, expectedStart, startDate) +} + +func TestHarvestClient_FetchEntries(t *testing.T) { + start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) + end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) + + expectedEntries := worklog.Entries{ + { + Client: worklog.IDNameField{ + ID: "1", + Name: "My Awesome Company", + }, + Project: worklog.IDNameField{ + ID: "11", + Name: "MARVEL", + }, + Task: worklog.IDNameField{ + ID: "111", + Name: "CPT-2014", + }, + Summary: "I met with The Winter Soldier", + Notes: "I met with The Winter Soldier", + Start: start, + BillableDuration: time.Hour * 2, + UnbillableDuration: 0, + }, + { + Client: worklog.IDNameField{ + ID: "1", + Name: "My Awesome Company", + }, + Project: worklog.IDNameField{ + ID: "11", + Name: "MARVEL", + }, + Task: worklog.IDNameField{ + ID: "111", + Name: "CPT-2014", + }, + Summary: "I helped him to get back on track", + Notes: "I helped him to get back on track", + Start: start, + BillableDuration: 0, + UnbillableDuration: time.Hour * 3, + }, + } + + mockServer := newMockServer(t, &mockServerOpts{ + Path: harvest.PathWorklog, + QueryParams: url.Values{ + "page": {"1"}, + "per_page": {"50"}, + "from": {utils.DateFormatRFC3339UTC.Format(start)}, + "to": {utils.DateFormatRFC3339UTC.Format(end)}, + "user_id": {"987654321"}, + "is_running": {"false"}, + "user_agent": {"github.com/gabor-boros/minutes"}, + }, + Method: http.MethodGet, + StatusCode: http.StatusOK, + Token: "Bearer t-o-k-e-n", + TokenHeader: "Authorization", + ResponseData: &harvest.FetchResponse{ + TimeEntries: []harvest.FetchEntry{ + { + Client: worklog.IntIDNameField{ + ID: 1, + Name: "My Awesome Company", + }, + Project: worklog.IntIDNameField{ + ID: 11, + Name: "MARVEL", + }, + Task: worklog.IntIDNameField{ + ID: 111, + Name: "CPT-2014", + }, + Notes: "I met with The Winter Soldier", + SpentDate: utils.DateFormatISO8601.Format(start), + Hours: 2.0, + CreatedAt: start, + Billable: true, + IsRunning: false, + }, + { + Client: worklog.IntIDNameField{ + ID: 1, + Name: "My Awesome Company", + }, + Project: worklog.IntIDNameField{ + ID: 11, + Name: "MARVEL", + }, + Task: worklog.IntIDNameField{ + ID: 111, + Name: "CPT-2014", + }, + Notes: "I helped him to get back on track", + SpentDate: utils.DateFormatISO8601.Format(start), + Hours: 3.0, + CreatedAt: start, + Billable: false, + IsRunning: false, + }, + }, + PerPage: 50, + TotalEntries: 2, + }, + }) + defer mockServer.Close() + + harvestClient, err := harvest.NewFetcher(&harvest.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{ + Timeout: client.DefaultRequestTimeout, + }, + TokenAuth: client.TokenAuth{ + Header: "Authorization", + TokenName: "Bearer", + Token: "t-o-k-e-n", + }, + BaseURL: mockServer.URL, + Account: 123456789, + }) + require.Nil(t, err) + + entries, err := harvestClient.FetchEntries(context.Background(), &client.FetchOpts{ + User: "987654321", + Start: start, + End: end, + }) + + require.Nil(t, err, "cannot fetch entries") + require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") +} diff --git a/internal/pkg/client/toggl/toggl.go b/internal/pkg/client/toggl/toggl.go index 5942fc4..7554684 100644 --- a/internal/pkg/client/toggl/toggl.go +++ b/internal/pkg/client/toggl/toggl.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" "strconv" - "strings" "time" "github.com/gabor-boros/minutes/internal/pkg/client" @@ -140,15 +139,10 @@ func (c *togglClient) fetchEntries(ctx context.Context, reqURL string) (interfac } func (c *togglClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { - userID, err := strconv.Atoi(strings.Split(opts.User, ",")[0]) - if err != nil { - return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) - } - fetchURL, err := c.URL(PathWorklog, map[string]string{ "since": utils.DateFormatISO8601.Format(opts.Start), "until": utils.DateFormatISO8601.Format(opts.End), - "user_id": strconv.Itoa(userID), + "user_id": opts.User, "workspace_id": strconv.Itoa(c.workspace), "user_agent": "github.com/gabor-boros/minutes", }) diff --git a/internal/pkg/utils/time.go b/internal/pkg/utils/time.go index c2ccc55..691ba2d 100644 --- a/internal/pkg/utils/time.go +++ b/internal/pkg/utils/time.go @@ -34,3 +34,8 @@ func (d DateFormat) String() string { func (d DateFormat) Format(t time.Time) string { return t.Format(d.String()) } + +// Parse the given string with the specified layout. +func (d DateFormat) Parse(s string) (time.Time, error) { + return time.Parse(d.String(), s) +} diff --git a/internal/pkg/worklog/entry.go b/internal/pkg/worklog/entry.go index a57530b..d089785 100644 --- a/internal/pkg/worklog/entry.go +++ b/internal/pkg/worklog/entry.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "regexp" + "strconv" "time" ) @@ -19,6 +20,24 @@ func (f IDNameField) IsComplete() bool { return f.ID != "" && f.Name != "" } +// IntIDNameField stands for every field that has an int ID and string Name. +// This field struct is a helper struct that should be avoided in the code, +// but should serve a good use in client implementation. The field has a +// method ConvertToIDNameField to convert itself into an IDNameField. +type IntIDNameField struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// ConvertToIDNameField creates an IDName field from itself. +// ConvertToIDNameField should be called when it leaves client context. +func (f *IntIDNameField) ConvertToIDNameField() IDNameField { + return IDNameField{ + ID: strconv.Itoa(f.ID), + Name: f.Name, + } +} + // Entries defines a collection of entries. type Entries []Entry diff --git a/internal/pkg/worklog/entry_test.go b/internal/pkg/worklog/entry_test.go index f03474c..f90741b 100644 --- a/internal/pkg/worklog/entry_test.go +++ b/internal/pkg/worklog/entry_test.go @@ -42,6 +42,20 @@ func getIncompleteTestEntry() worklog.Entry { return entry } +func TestIntIDNameField_ConvertToIDNameField(t *testing.T) { + field := worklog.IntIDNameField{ + ID: 1234, + Name: "Test", + } + + expectedField := worklog.IDNameField{ + ID: "1234", + Name: "Test", + } + + require.Equal(t, field.ConvertToIDNameField(), expectedField) +} + func TestIDNameFieldIsComplete(t *testing.T) { var field worklog.IDNameField diff --git a/www/docs/index.md b/www/docs/index.md index d8a24ef..fc95098 100644 --- a/www/docs/index.md +++ b/www/docs/index.md @@ -68,7 +68,7 @@ The following platforms and tools are supported. If you miss your favorite tool, | 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 | diff --git a/www/docs/sources/harvest.md b/www/docs/sources/harvest.md new file mode 100644 index 0000000..9fc6cd3 --- /dev/null +++ b/www/docs/sources/harvest.md @@ -0,0 +1,57 @@ +Source documentation for [Harvest](https://getharvest.com/). + +## Field mappings + +The source makes the following special mappings. + +| From | To | Description | +| ----- | -------------- | --------------------------------------------------------------------------------- | +| Notes | Notes, Summary | Notes are mapped to both notes and summary as that was the most meaningful option | + +## CLI flags + +The source provides to following extra CLI flags. + +```plaintext +Flags: + --harvest-account int set the Account ID + --harvest-api-key string set the API key +``` + +## Configuration options + +The source provides the following extra configuration options. + +| Config option | Kind | Description | Example | +| --------------- | ------ | ------------------------------------------- | ----------------------------- | +| harvest-account | string | The account ID where the API key belongs to | harvest-account = 123456789 | +| harvest-api-key | string | API key gathered from Harvest[^1] | harvest-api-key = "" | + +## Limitations + +* Harvest does not support tags which makes it impossible to get tasks from tags. A workaround is [planned](https://github.com/gabor-boros/minutes/issues/32). + +## Example configuration + +```toml +# Source config +source = "harvest" +source-user = "" + +harvest-account = "" +harvest-api-key = "" + +# Target config +target = "tempo" +target-user = "" + +tempo-url = "https://.atlassian.net" +tempo-username = "" +tempo-password = "" + +# General config +round-to-closest-minute = true +force-billed-duration = true +``` + +[^1]: Create a new "Personal Access Token" on the [developer settings](https://id.getharvest.com/developers) panel. diff --git a/www/mkdocs.yml b/www/mkdocs.yml index 7233a36..28b8ce8 100644 --- a/www/mkdocs.yml +++ b/www/mkdocs.yml @@ -63,6 +63,7 @@ nav: - configuration.md - Sources: - Clockify: sources/clockify.md + - Harvest: sources/harvest.md - Tempo: sources/tempo.md - Timewarrior: sources/timewarrior.md - Toggl Track: sources/toggl.md