From a8fa50b4e9344fba6f8681816689336f94ed8e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Boros?= Date: Fri, 22 Oct 2021 19:25:56 +0200 Subject: [PATCH] refactor: rework client composition logic and remove unnecessary Toggl flag (#30) * refactor: create Entries type * refactor(tempo): use new Entries type and inline entry group upload * refactor: add date time helpers * refactor: reorganize client composition Do a major refactor on how the client composition is done and simplify client logic. Besides that, this commit reduces the duplicated logic across clients and outsources the authentication setter. Additionally, it makes it easier to integrate new clients with more complex authentication methods, like Oauth2. Also, the commit contains a change that removes the illogical flag for setting toggl client base URL, hence that never changes. --- cmd/root.go | 185 +++---- internal/cmd/utils/printer.go | 6 +- internal/cmd/utils/tracker.go | 2 +- internal/pkg/client/client.go | 305 ++++++----- internal/pkg/client/client_test.go | 483 ++++++++++++++---- internal/pkg/client/clockify/clockify.go | 122 +++-- internal/pkg/client/clockify/clockify_test.go | 38 +- internal/pkg/client/fetcher.go | 42 ++ internal/pkg/client/tempo/tempo.go | 189 +++---- internal/pkg/client/tempo/tempo_test.go | 95 ++-- .../pkg/client/timewarrior/timewarrior.go | 104 ++-- .../client/timewarrior/timewarrior_test.go | 69 +-- internal/pkg/client/toggl/toggl.go | 102 ++-- internal/pkg/client/toggl/toggl_test.go | 45 +- internal/pkg/client/uploader.go | 80 +++ internal/pkg/client/uploader_test.go | 95 ++++ internal/pkg/utils/time.go | 36 ++ internal/pkg/worklog/entry.go | 20 +- internal/pkg/worklog/entry_test.go | 14 +- internal/pkg/worklog/worklog.go | 16 +- internal/pkg/worklog/worklog_test.go | 28 +- www/docs/sources/toggl.md | 2 - 22 files changed, 1317 insertions(+), 761 deletions(-) create mode 100644 internal/pkg/client/fetcher.go create mode 100644 internal/pkg/client/uploader.go create mode 100644 internal/pkg/client/uploader_test.go create mode 100644 internal/pkg/utils/time.go diff --git a/cmd/root.go b/cmd/root.go index 9ca65a7..6abe5e0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,20 +4,19 @@ import ( "context" "errors" "fmt" - "net/http" - "net/url" "os" "os/exec" "regexp" "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" "github.com/gabor-boros/minutes/internal/pkg/client/clockify" + + "github.com/gabor-boros/minutes/internal/pkg/client/toggl" + + "github.com/gabor-boros/minutes/internal/cmd/utils" "github.com/gabor-boros/minutes/internal/pkg/client/tempo" "github.com/jedib0t/go-pretty/v6/progress" @@ -161,7 +160,6 @@ func initTimewarriorFlags() { } 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") } @@ -238,109 +236,65 @@ func validateFlags() { } } -func getClientOpts(urlFlag string, usernameFlag string, passwordFlag string, tokenFlag string, tokenHeader string) (*client.BaseClientOpts, error) { - opts := &client.BaseClientOpts{ - HTTPClientOpts: client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - TokenHeader: tokenHeader, - }, - TagsAsTasks: viper.GetBool("tags-as-tasks"), - TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"), - } - - baseURL, err := url.Parse(viper.GetString(urlFlag)) - if err != nil { - return opts, err - } - - if usernameFlag != "" { - opts.Username = viper.GetString(usernameFlag) - } - - if passwordFlag != "" { - opts.Password = viper.GetString(passwordFlag) - } - - if tokenFlag != "" { - opts.Token = viper.GetString(tokenFlag) - } - - opts.BaseURL = baseURL.String() - - return opts, nil -} - func getFetcher() (client.Fetcher, error) { switch viper.GetString("source") { case "clockify": - opts, err := getClientOpts( - "clockify-url", - "", - "", - "clockify-api-key", - "X-Api-Key", - ) - - if err != nil { - return nil, err - } - - return clockify.NewClient(&clockify.ClientOpts{ - BaseClientOpts: *opts, - Workspace: viper.GetString("clockify-workspace"), - }), nil + return clockify.NewFetcher(&clockify.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{ + TagsAsTasks: viper.GetBool("tags-as-tasks"), + TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"), + Timeout: 0, + }, + TokenAuth: client.TokenAuth{ + Header: "X-Api-Key", + Token: viper.GetString("clockify-api-key"), + }, + BaseURL: viper.GetString("clockify-url"), + Workspace: viper.GetString("clockify-workspace"), + }) case "tempo": - opts, err := getClientOpts( - "tempo-url", - "tempo-username", - "tempo-password", - "", - "", - ) - - if err != nil { - return nil, err - } - - return tempo.NewClient(&tempo.ClientOpts{ - BaseClientOpts: *opts, - }), nil + return tempo.NewFetcher(&tempo.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{ + TagsAsTasks: viper.GetBool("tags-as-tasks"), + TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"), + Timeout: 0, + }, + BasicAuth: client.BasicAuth{ + Username: viper.GetString("tempo-username"), + Password: viper.GetString("tempo-password"), + }, + BaseURL: viper.GetString("tempo-url"), + }) 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"), + return timewarrior.NewFetcher(&timewarrior.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{ + TagsAsTasks: viper.GetBool("tags-as-tasks"), + TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"), + Timeout: 0, + }, + CLIClient: client.CLIClient{ + 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"), }) 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 + return toggl.NewFetcher(&toggl.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{ + TagsAsTasks: viper.GetBool("tags-as-tasks"), + TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"), + Timeout: 0, + }, + BasicAuth: client.BasicAuth{ + Username: viper.GetString("toggl-api-key"), + Password: "api_token", + }, + BaseURL: "https://api.track.toggl.com", + Workspace: viper.GetInt("toggl-workspace"), + }) default: return nil, ErrNoSourceImplementation } @@ -349,21 +303,18 @@ func getFetcher() (client.Fetcher, error) { func getUploader() (client.Uploader, error) { switch viper.GetString("target") { case "tempo": - opts, err := getClientOpts( - "tempo-url", - "tempo-username", - "tempo-password", - "", - "", - ) - - if err != nil { - return nil, err - } - - return tempo.NewClient(&tempo.ClientOpts{ - BaseClientOpts: *opts, - }), nil + return tempo.NewUploader(&tempo.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{ + TagsAsTasks: viper.GetBool("tags-as-tasks"), + TagsAsTasksRegex: viper.GetString("tags-as-tasks-regex"), + Timeout: 0, + }, + BasicAuth: client.BasicAuth{ + Username: viper.GetString("tempo-username"), + Password: viper.GetString("tempo-password"), + }, + BaseURL: viper.GetString("tempo-url"), + }) default: return nil, ErrNoTargetImplementation } diff --git a/internal/cmd/utils/printer.go b/internal/cmd/utils/printer.go index 946b2b0..73124cc 100644 --- a/internal/cmd/utils/printer.go +++ b/internal/cmd/utils/printer.go @@ -60,7 +60,7 @@ type TableColumnConfig struct { type Printer interface { // Print prints out the list of complete and incomplete entries. // The output location must be set through `BasePrinterOpts`. - Print(completeEntries []worklog.Entry, incompleteEntries []worklog.Entry) error + Print(completeEntries worklog.Entries, incompleteEntries worklog.Entries) error } // BasePrinterOpts represents the configuration for common printer options. @@ -111,7 +111,7 @@ func (p *tablePrinter) convertEntryToRow(entry *worklog.Entry) table.Row { } } -func (p *tablePrinter) generateRows(entries []worklog.Entry, billable *time.Duration, unbillable *time.Duration) { +func (p *tablePrinter) generateRows(entries worklog.Entries, billable *time.Duration, unbillable *time.Duration) { for i := range entries { entry := entries[i] *billable += entry.BillableDuration @@ -120,7 +120,7 @@ func (p *tablePrinter) generateRows(entries []worklog.Entry, billable *time.Dura } } -func (p *tablePrinter) Print(completeEntries []worklog.Entry, incompleteEntries []worklog.Entry) error { +func (p *tablePrinter) Print(completeEntries worklog.Entries, incompleteEntries worklog.Entries) error { var totalBillable time.Duration var totalUnbillable time.Duration diff --git a/internal/cmd/utils/tracker.go b/internal/cmd/utils/tracker.go index 4fe873c..1e4395d 100644 --- a/internal/cmd/utils/tracker.go +++ b/internal/cmd/utils/tracker.go @@ -15,7 +15,7 @@ func NewProgressWriter(updateFrequency time.Duration) progress.Writer { writer.ShowValue(false) writer.SetAutoStop(true) - writer.SetTrackerPosition(progress.PositionRight) + // writer.StartTrackingPosition(progress.PositionRight) writer.SetMessageWidth(50) writer.SetUpdateFrequency(updateFrequency) diff --git a/internal/pkg/client/client.go b/internal/pkg/client/client.go index 9d1a8c4..b0ddb59 100644 --- a/internal/pkg/client/client.go +++ b/internal/pkg/client/client.go @@ -7,47 +7,34 @@ import ( "errors" "fmt" "io" + "io/ioutil" "net/http" - "net/url" + netURL "net/url" + "os/exec" "time" +) - "github.com/jedib0t/go-pretty/v6/progress" - - "github.com/gabor-boros/minutes/internal/pkg/worklog" +const ( + // DefaultRequestTimeout sets the timeout for the HTTP requests or command + // executions. + DefaultRequestTimeout = time.Second * 30 ) var ( - // ErrFetchEntries wraps the error when fetch failed. - ErrFetchEntries = errors.New("failed to fetch entries") - // ErrUploadEntries wraps the error when upload failed. - ErrUploadEntries = errors.New("failed to upload entries") + // ErrNoBaseURL returns when HTTP based clients has no BaseURL set, but its + // `URL()` method was called. + ErrNoBaseURL = errors.New("no BaseURL provided") + // ErrInvalidBasicAuth returns if any of the provided basic auth parameters + // are empty. + ErrInvalidBasicAuth = errors.New("invalid basic auth params provided") + // ErrInvalidTokenAuth returns if the provided token is empty. + ErrInvalidTokenAuth = errors.New("invalid token auth params provided") ) -// HTTPClientOpts specifies all options that are required for HTTP clients. -type HTTPClientOpts struct { - HTTPClient *http.Client - // BaseURL for the API, without a trailing slash. - BaseURL string - // Username used for authentication. - Username string - // Password used for authentication. - // - // If both Password and Token are set, Token takes precedence. - Password string - // Token is the API token used by the source our target API. - // - // If Token is set, TokenHeader must not be empty. - // If both Password and Token are set, Token takes precedence. - Token string - // TokenHeader is the header name that contains the auth token. - TokenHeader string -} - // BaseClientOpts specifies the common options the clients are using. // When a client needs other options as well, it composes a new set of options // using BaseClientOpts. type BaseClientOpts struct { - HTTPClientOpts // TagsAsTasks defines to use tag names to determine the task. // Using TagsAsTasks can be useful if the user's workflow involves // splitting activity across multiple tasks, or when the user has no option @@ -60,118 +47,195 @@ type BaseClientOpts struct { // // This option must be used in conjunction with TagsAsTasks option. TagsAsTasksRegex string + // Timeout sets the timeout for the client to execute a request. + // In the case of HTTP clients, the timeout is applied on the HTTP request, + // while in the case of CLI based clients it will be applied on the command + // execution. + Timeout time.Duration } -// FetchOpts specifies the only options for Fetchers. -// In contract to the BaseClientOpts, these options shall not be extended or -// overridden. -type FetchOpts struct { - User string - Start time.Time - End time.Time -} - -// Fetcher specifies the functions used to fetch worklog entries. -type Fetcher interface { - // FetchEntries from a given source and return the list of worklog entries - // If the fetching resulted in an error, the list of worklog entries will be - // nil and an error will return. - FetchEntries(ctx context.Context, opts *FetchOpts) ([]worklog.Entry, error) -} - -// UploadOpts specifies the only options for the Uploader. In contrast to the -// BaseClientOpts, these options shall not be extended or overridden. -type UploadOpts struct { - // RoundToClosestMinute indicates to round the billed and unbilled duration - // separately to the closest minute. - // If the elapsed time is 30 seconds or more, the closest minute is the - // next minute, otherwise the previous one. In case the previous minute is - // 0 (zero), then 0 (zero) will be used for the billed and/or unbilled - // duration. - RoundToClosestMinute bool - // TreatDurationAsBilled indicates to use every time spent as billed. - TreatDurationAsBilled bool - // CreateMissingResources indicates the need of resource creation if the - // resource is missing. - // In the case of some Uploader, the resources must exist to be able to - // use them by their ID or name. - CreateMissingResources bool - // User represents the user in which name the time log will be uploaded. - User string - // ProgressWriter represents a writer that tracks the upload progress. - // In case the ProgressWriter is nil, that means the upload progress should - // not be tracked, hence, that's not an error. - ProgressWriter progress.Writer -} - -// Uploader specifies the functions used to upload worklog entries. -type Uploader interface { - // UploadEntries to a given target. - // If the upload resulted in an error, the upload will stop and an error - // will return. - UploadEntries(ctx context.Context, entries []worklog.Entry, errChan chan error, opts *UploadOpts) -} - -// FetchUploader is the combination of Fetcher and Uploader. -// The FetchUploader can to fetch entries from and upload to a given resource. -type FetchUploader interface { - Fetcher - Uploader -} - -// SendRequestOpts represents the parameters needed for sending a request. -// Since SendRequest is for sending requests to HTTP based APIs, it receives -// the HTTPClientOpts as well for its options. -type SendRequestOpts struct { - Method string - Path string - ClientOpts *HTTPClientOpts - Data interface{} -} - -// SendRequest is a helper for any Fetcher and Uploader that must APIs. -// The SendRequest function prepares a new HTTP request, sends it and returns -// the response for further parsing. If the response status is not 200 or 201, -// the function returns an error. -func SendRequest(ctx context.Context, opts *SendRequestOpts) (*http.Response, error) { - var err error - var marshalledData []byte +// Authenticator is responsible for setting the necessary parameters for +// authentication on the request. +type Authenticator interface { + // SetAuthHeader sets the auth header on HTTP requests before the HTTPClient + // sends it. + SetAuthHeader(req *http.Request) +} + +// BasicAuth represents the required parameters for username and password based +// authentication +type BasicAuth struct { + Username string + Password string +} + +func (a *BasicAuth) SetAuthHeader(req *http.Request) { + req.SetBasicAuth(a.Username, a.Password) +} + +// NewBasicAuth returns a new BasicAuth that implements Authenticator. +func NewBasicAuth(username string, password string) (Authenticator, error) { + if username == "" || password == "" { + return nil, ErrInvalidBasicAuth + } + + return &BasicAuth{ + Username: username, + Password: password, + }, nil +} + +// TokenAuth represents the required parameters for token based authentication. +type TokenAuth struct { + Header string + Token string +} + +func (a *TokenAuth) SetAuthHeader(req *http.Request) { + req.Header.Set(a.Header, a.Token) +} + +// NewTokenAuth returns a new TokenAuth that implements Authenticator. If the +// header name is not set, the standard "Authorization" header will be used. +func NewTokenAuth(header string, token string) (Authenticator, error) { + if token == "" { + return nil, ErrInvalidTokenAuth + } + + if header == "" { + header = "Authorization" + } + + return &TokenAuth{ + Header: header, + Token: token, + }, nil +} + +// CLIExecuteOpts represents the options that CLI client's Execute method +// receives. +type CLIExecuteOpts struct { + Timeout time.Duration +} + +// CLIClient implements a client that communicates with a CLI tool. +// The CommandArguments parameter is not used by CLIClient, but those structs +// that uses it for composition. +type CLIClient struct { + Command string + CommandArguments []string + CommandCtxExecutor func(ctx context.Context, name string, arg ...string) *exec.Cmd +} + +// Execute runs the given CLI command with the specified arguments. +func (c *CLIClient) Execute(ctx context.Context, arguments []string, opts *CLIExecuteOpts) ([]byte, error) { + ctxWithTimeout, cancel := ctxWithTimeout(ctx, opts.Timeout) + defer cancel() - requestURL, err := url.Parse(opts.ClientOpts.BaseURL + opts.Path) + return c.CommandCtxExecutor(ctxWithTimeout, c.Command, arguments...).Output() // #nosec G204 +} + +// HTTPRequestOpts represents the call options for an HTTP request, fired by the +// HTTPClient when `Call` method is called. +type HTTPRequestOpts struct { + Method string + Url string + Data interface{} + Headers map[string]string + Auth Authenticator + Timeout time.Duration +} + +// HTTPClient implements a client that communicates with the server over HTTP. +type HTTPClient struct { + Client *http.Client + BaseURL *netURL.URL +} + +// URL returns the BaseURL combined with the provided params as query params if +// the BaseURL is set. Otherwise, it returns an `ErrNoBaseURL` error. +func (c *HTTPClient) URL(path string, params map[string]string) (string, error) { + if c.BaseURL == nil { + return "", ErrNoBaseURL + } + + urlPath, err := netURL.Parse(path) + if err != nil { + return "", err + } + + url := c.BaseURL.ResolveReference(urlPath) + + query := url.Query() + + for key, val := range params { + query.Add(key, val) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +// Call fires an HTTP request with the given method and body (in its body) to +// the API URL returned by the `URL` method. +func (c *HTTPClient) Call(ctx context.Context, opts *HTTPRequestOpts) ([]byte, error) { + ctxWithTimeout, cancel := ctxWithTimeout(ctx, opts.Timeout) + defer cancel() + + req, err := c.newRequest(ctxWithTimeout, opts) if err != nil { return nil, err } + resp, err := c.sendRequest(c.Client, req) + if err != nil { + return nil, err + } + + return ioutil.ReadAll(resp.Body) +} + +func (c *HTTPClient) newRequest(ctx context.Context, opts *HTTPRequestOpts) (*http.Request, error) { + var err error + var body []byte + if opts.Data != nil { - marshalledData, err = json.Marshal(opts.Data) + body, err = json.Marshal(opts.Data) if err != nil { return nil, err } } - req, err := http.NewRequestWithContext(ctx, opts.Method, requestURL.String(), bytes.NewBuffer(marshalledData)) + req, err := http.NewRequestWithContext(ctx, opts.Method, opts.Url, bytes.NewBuffer(body)) if err != nil { return nil, err } - req.Header.Add("Content-Type", "application/json") + for key, val := range opts.Headers { + req.Header.Set(key, val) + } - if opts.ClientOpts.Token != "" { - if opts.ClientOpts.TokenHeader == "" { - return nil, errors.New("no token header name") - } + if opts.Auth != nil { + opts.Auth.SetAuthHeader(req) + } + + return req, err +} - req.Header.Add(opts.ClientOpts.TokenHeader, opts.ClientOpts.Token) - } else { - req.SetBasicAuth(opts.ClientOpts.Username, opts.ClientOpts.Password) +func (c *HTTPClient) sendRequest(httpClient *http.Client, req *http.Request) (*http.Response, error) { + // Set a default HTTP client if no clients were set + if httpClient == nil { + httpClient = http.DefaultClient } - resp, err := opts.ClientOpts.HTTPClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } - if !(resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated) { + // If the response wasn't successful, return an error containing the error code + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#successful_responses + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { errBody, err := io.ReadAll(resp.Body) if err != nil { return nil, err @@ -182,3 +246,12 @@ func SendRequest(ctx context.Context, opts *SendRequestOpts) (*http.Response, er return resp, nil } + +func ctxWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, func()) { + ctxTimeout := DefaultRequestTimeout + if timeout > 0 { + ctxTimeout = timeout + } + + return context.WithTimeout(ctx, ctxTimeout) +} diff --git a/internal/pkg/client/client_test.go b/internal/pkg/client/client_test.go index 38930b7..6c323aa 100644 --- a/internal/pkg/client/client_test.go +++ b/internal/pkg/client/client_test.go @@ -3,15 +3,26 @@ package client_test import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "net/url" + "os" + "os/exec" "reflect" + "strconv" "testing" "github.com/gabor-boros/minutes/internal/pkg/client" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var ( + mockedExitCode int + mockedStdout string +) + type testData struct { Message string `json:"message"` } @@ -27,15 +38,25 @@ func getDataType(data interface{}) (res string) { return res + t.Name() } +func mockedExecCommand(_ context.Context, command string, args ...string) *exec.Cmd { + arguments := []string{"-test.run=TestExecCommandHelper", "--", command} + arguments = append(arguments, args...) + cmd := exec.Command(os.Args[0], arguments...) + + cmd.Env = []string{"GO_TEST_HELPER_PROCESS=1", + "STDOUT=" + mockedStdout, + "EXIT_CODE=" + strconv.Itoa(mockedExitCode), + } + + return cmd +} + type mockServerOpts struct { Path string Method string StatusCode int - Username string - Password string + Headers map[string]string RequestData interface{} - Token string - TokenHeader string } func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { @@ -43,15 +64,12 @@ func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { 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") - if e.Username != "" && e.Password != "" { - username, password, _ := r.BasicAuth() - require.Equal(t, e.Username, username, "API call basic auth username mismatch") - require.Equal(t, e.Password, password, "API call basic auth password mismatch") - } + for key, values := range r.Header { + expected := values[0] + actual, ok := e.Headers[key] - if e.Token != "" { - headerValue := r.Header.Get(e.TokenHeader) - require.Equal(t, e.Token, headerValue, "API call auth token mismatch") + assert.True(t, ok, fmt.Sprintf("header key \"%s\" is not set", key)) + require.Equal(t, expected, actual) } if e.RequestData != nil { @@ -64,6 +82,7 @@ func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { t.Fatalf("%s is not a known data type", dataType) } + // Parse the request if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Fatal(err) } @@ -74,156 +93,402 @@ func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { } func newMockServer(t *testing.T, opts *mockServerOpts) *httptest.Server { + if opts.Headers == nil { + opts.Headers = map[string]string{} + } + + defaultHeaders := map[string]string{ + "User-Agent": "Go-http-client/1.1", + "Accept-Encoding": "gzip", + } + + // Set default headers if not set + for key, val := range defaultHeaders { + if _, ok := opts.Headers[key]; !ok { + opts.Headers[key] = val + } + } + mockServer := mockServer(t, opts) require.NotNil(t, mockServer, "cannot create mock server") return mockServer } -func TestSendRequest_GET(t *testing.T) { - mockServer := newMockServer(t, &mockServerOpts{ - Path: "/endpoint", - Method: http.MethodGet, - StatusCode: http.StatusOK, - }) - defer mockServer.Close() +func TestBasicAuth(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "", nil) + require.Nil(t, err) - resp, err := client.SendRequest(context.Background(), &client.SendRequestOpts{ - Method: http.MethodGet, - Path: "/endpoint", - ClientOpts: &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - }, - Data: nil, - }) + _, _, ok := req.BasicAuth() + require.False(t, ok) + + auth, err := client.NewBasicAuth("steve", "rogers") + require.Nil(t, err) + + auth.SetAuthHeader(req) + + username, password, ok := req.BasicAuth() + assert.True(t, ok) + require.Equal(t, username, "steve") + require.Equal(t, password, "rogers") +} + +func TestBasicAuth_Invalid(t *testing.T) { + var err error + + _, err = client.NewBasicAuth("", "") + require.ErrorIs(t, err, client.ErrInvalidBasicAuth) + + _, err = client.NewBasicAuth("steve", "") + require.ErrorIs(t, err, client.ErrInvalidBasicAuth) + + _, err = client.NewBasicAuth("", "rogers") + require.ErrorIs(t, err, client.ErrInvalidBasicAuth) +} + +func TestTokenAuth(t *testing.T) { + header := "X-API-Token" + + req, err := http.NewRequest(http.MethodGet, "", nil) + require.Nil(t, err) + require.Equal(t, req.Header.Get(header), "") + + auth, err := client.NewTokenAuth(header, "the-strongest-avenger") + require.Nil(t, err) + + auth.SetAuthHeader(req) + require.Equal(t, req.Header.Get(header), "the-strongest-avenger") +} + +func TestTokenAuth_FallbackHeader(t *testing.T) { + header := "Authorization" + + req, err := http.NewRequest(http.MethodGet, "", nil) + require.Nil(t, err) + require.Equal(t, req.Header.Get(header), "") + + auth, err := client.NewTokenAuth("", "the-strongest-avenger") + require.Nil(t, err) + + auth.SetAuthHeader(req) + require.Equal(t, req.Header.Get(header), "the-strongest-avenger") +} + +func TestTokenAuth_Invalid(t *testing.T) { + var err error + + _, err = client.NewTokenAuth("", "") + require.ErrorIs(t, err, client.ErrInvalidTokenAuth) +} + +// TestExecCommandHelper is a helper test case that will be called by `mockedExecCommand`. +// This workaround is needed to be able to "mock" system calls. +func TestExecCommandHelper(t *testing.T) { + // Not executed by the mocked command function, so return + if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" { + return + } + + _, _ = fmt.Fprint(os.Stdout, os.Getenv("STDOUT")) + exitCode, _ := strconv.Atoi(os.Getenv("EXIT_CODE")) + os.Exit(exitCode) +} + +func TestCLIClient_Execute(t *testing.T) { + mockedExitCode = 0 + mockedStdout = "[]" + + cliClient := client.CLIClient{ + Command: "some-command", + CommandArguments: []string{}, + CommandCtxExecutor: mockedExecCommand, + } + + out, err := cliClient.Execute(context.Background(), cliClient.CommandArguments, &client.CLIExecuteOpts{}) + require.Nil(t, err) + require.Equal(t, string(out), "[]") +} + +func TestHTTPClient_URL(t *testing.T) { + baseURL, err := url.Parse("https://example.com") + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + urlParams := map[string]string{ + "param1": "value1", + "param2": "value2", + } + + expectedURL := "https://example.com/test?param1=value1¶m2=value2" + combinedURL, err := httpClient.URL("/test", urlParams) + + require.Nil(t, err) + require.Equal(t, combinedURL, expectedURL) +} - require.Nil(t, err, "request failed") - require.Equal(t, http.StatusOK, resp.StatusCode) +func TestHTTPClient_URL_Invalid(t *testing.T) { + baseURL, err := url.Parse("https://example.com") + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + _, err = httpClient.URL("https://not a real path", map[string]string{}) + require.Error(t, err) +} + +func TestHTTPClient_URL_NoPath(t *testing.T) { + baseURL, err := url.Parse("https://example.com") + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + urlParams := map[string]string{ + "param1": "value1", + "param2": "value2", + } + + expectedURL := "https://example.com?param1=value1¶m2=value2" + combinedURL, err := httpClient.URL("", urlParams) + + require.Nil(t, err) + require.Equal(t, combinedURL, expectedURL) +} + +func TestHTTPClient_URL_Empty(t *testing.T) { + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + } + + combinedURL, err := httpClient.URL("", map[string]string{}) + + require.ErrorIs(t, err, client.ErrNoBaseURL) + require.Empty(t, combinedURL) } -func TestSendRequest_POST(t *testing.T) { - data := &testData{ - Message: "expected post request data", +func TestHTTPClient_URL_NoBaseURL(t *testing.T) { + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + } + + urlParams := map[string]string{ + "param1": "value1", + "param2": "value2", + } + + combinedURL, err := httpClient.URL("", urlParams) + + require.ErrorIs(t, err, client.ErrNoBaseURL) + require.Empty(t, combinedURL) +} + +func TestHTTPClient_URL_NoParams(t *testing.T) { + baseURL, err := url.Parse("https://example.com") + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + combinedURL, err := httpClient.URL("", map[string]string{}) + + require.Nil(t, err) + require.Equal(t, combinedURL, baseURL.String()) +} + +func TestHTTPClient_Call(t *testing.T) { + path := "/endpoint" + method := http.MethodGet + headers := map[string]string{ + "Test": "Header", } mockServer := newMockServer(t, &mockServerOpts{ - Path: "/endpoint", - Method: http.MethodPost, - StatusCode: http.StatusOK, - RequestData: data, + Path: path, + Method: method, + StatusCode: http.StatusOK, + Headers: headers, }) defer mockServer.Close() - requestOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, + baseURL, err := url.Parse(mockServer.URL) + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, } - resp, err := client.SendRequest(context.Background(), &client.SendRequestOpts{ - Method: http.MethodPost, - Path: "/endpoint", - ClientOpts: requestOpts, - Data: data, + requestURL, err := httpClient.URL(path, map[string]string{}) + require.Nil(t, err) + + resp, err := httpClient.Call(context.Background(), &client.HTTPRequestOpts{ + Method: method, + Url: requestURL, + Auth: nil, + Headers: headers, + Data: nil, }) - require.Nil(t, err, "request failed") - require.Equal(t, http.StatusOK, resp.StatusCode) + require.Nil(t, err, err) + require.Equal(t, []byte{}, resp) } -func TestSendRequest_BasicAuth(t *testing.T) { +func TestHTTPClient_Call_NoClientSet(t *testing.T) { + path := "/endpoint" + method := http.MethodGet + headers := map[string]string{ + "Test": "Header", + } + mockServer := newMockServer(t, &mockServerOpts{ - Path: "/endpoint", - Method: http.MethodGet, + Path: path, + Method: method, StatusCode: http.StatusOK, - Username: "Thor", - Password: "The strongest Avenger", + Headers: headers, }) defer mockServer.Close() - resp, err := client.SendRequest(context.Background(), &client.SendRequestOpts{ - Method: http.MethodGet, - Path: "/endpoint", - ClientOpts: &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: "Thor", - Password: "The strongest Avenger", - }, - Data: nil, + baseURL, err := url.Parse(mockServer.URL) + require.Nil(t, err) + + httpClient := client.HTTPClient{ + BaseURL: baseURL, + } + + requestURL, err := httpClient.URL(path, map[string]string{}) + require.Nil(t, err) + + resp, err := httpClient.Call(context.Background(), &client.HTTPRequestOpts{ + Method: method, + Url: requestURL, + Auth: nil, + Headers: headers, + Data: nil, }) - require.Nil(t, err, "request failed") - require.Equal(t, http.StatusOK, resp.StatusCode) + require.Nil(t, err) + require.Equal(t, []byte{}, resp) } -func TestSendRequest_TokenAuth(t *testing.T) { +func TestHTTPClient_Call_POST(t *testing.T) { + path := "/endpoint" + method := http.MethodPost + mockServer := newMockServer(t, &mockServerOpts{ - Path: "/endpoint", - Method: http.MethodGet, - StatusCode: http.StatusOK, - Token: "t-o-k-e-n", - TokenHeader: "X-API-Token", + Path: path, + Method: method, + StatusCode: http.StatusOK, + Headers: map[string]string{ + "Content-Length": "18", + }, }) defer mockServer.Close() - resp, err := client.SendRequest(context.Background(), &client.SendRequestOpts{ - Method: http.MethodGet, - Path: "/endpoint", - ClientOpts: &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Token: "t-o-k-e-n", - TokenHeader: "X-API-Token", + baseURL, err := url.Parse(mockServer.URL) + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + requestURL, err := httpClient.URL(path, map[string]string{}) + require.Nil(t, err) + + resp, err := httpClient.Call(context.Background(), &client.HTTPRequestOpts{ + Method: method, + Url: requestURL, + Auth: nil, + Headers: map[string]string{}, + Data: testData{ + Message: "Test", }, - Data: nil, }) - require.Nil(t, err, "request failed") - require.Equal(t, http.StatusOK, resp.StatusCode) + require.Nil(t, err) + require.Equal(t, []byte{}, resp) } -func TestSendRequest_TokenAuth_NoHeader(t *testing.T) { +func TestHTTPClient_Call_Auth(t *testing.T) { + path := "/endpoint" + method := http.MethodGet + headers := map[string]string{ + "Authorization": "Basic c3RldmU6cm9nZXJz", + } + mockServer := newMockServer(t, &mockServerOpts{ - Path: "/endpoint", - Method: http.MethodGet, + Path: path, + Method: method, StatusCode: http.StatusOK, + Headers: headers, }) defer mockServer.Close() - resp, err := client.SendRequest(context.Background(), &client.SendRequestOpts{ - Method: http.MethodGet, - Path: "/endpoint", - ClientOpts: &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Token: "t-o-k-e-n", - TokenHeader: "", - }, - Data: nil, + baseURL, err := url.Parse(mockServer.URL) + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + requestURL, err := httpClient.URL(path, map[string]string{}) + require.Nil(t, err) + + authMethod, err := client.NewBasicAuth("steve", "rogers") + require.Nil(t, err) + + resp, err := httpClient.Call(context.Background(), &client.HTTPRequestOpts{ + Method: method, + Url: requestURL, + Auth: authMethod, + Headers: headers, + Data: nil, }) - require.Nil(t, resp, "request unexpectedly sent") - require.NotNil(t, err, "request unexpectedly succeeded") + require.Nil(t, err) + require.Equal(t, []byte{}, resp) } -func TestSendRequest_Error(t *testing.T) { +func TestHTTPClient_Call_Failure(t *testing.T) { + path := "/endpoint" + method := http.MethodGet + mockServer := newMockServer(t, &mockServerOpts{ - Path: "/endpoint", - Method: http.MethodGet, + Path: path, + Method: method, StatusCode: http.StatusInternalServerError, }) defer mockServer.Close() - resp, err := client.SendRequest(context.Background(), &client.SendRequestOpts{ - Method: http.MethodGet, - Path: "/endpoint", - ClientOpts: &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - }, - Data: nil, + baseURL, err := url.Parse(mockServer.URL) + require.Nil(t, err) + + httpClient := client.HTTPClient{ + Client: http.DefaultClient, + BaseURL: baseURL, + } + + requestURL, err := httpClient.URL(path, map[string]string{}) + require.Nil(t, err) + + _, err = httpClient.Call(context.Background(), &client.HTTPRequestOpts{ + Method: method, + Url: requestURL, + Auth: nil, + Headers: map[string]string{}, + Data: nil, }) - require.Nil(t, resp, "response unexpectedly succeeded") - require.NotNil(t, err, "response unexpectedly succeeded") + require.Error(t, err) } diff --git a/internal/pkg/client/clockify/clockify.go b/internal/pkg/client/clockify/clockify.go index 0bf78c2..ae6a504 100644 --- a/internal/pkg/client/clockify/clockify.go +++ b/internal/pkg/client/clockify/clockify.go @@ -12,12 +12,11 @@ import ( "strconv" "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 ( - // DateFormat is the specific format used by Clockify to parse time. - DateFormat string = "2006-01-02T15:04:05Z" // MaxPageLength is the maximum page length defined by Clockify. MaxPageLength int = 5000 // PathWorklog is the API endpoint used to search and create worklogs. @@ -62,54 +61,20 @@ type WorklogSearchParams struct { // ClientOpts is the client specific options, extending client.BaseClientOpts. type ClientOpts struct { client.BaseClientOpts + client.TokenAuth + BaseURL string Workspace string } type clockifyClient struct { - opts *ClientOpts + *client.BaseClientOpts + *client.HTTPClient + authenticator client.Authenticator + workspace string } -func (c *clockifyClient) getSearchURL(user string, params *WorklogSearchParams) (string, error) { - searchPath := fmt.Sprintf(PathWorklog, c.opts.Workspace, user) - worklogURL, err := url.Parse(c.opts.BaseURL + searchPath) - if err != nil { - return "", err - } - - queryParams := worklogURL.Query() - queryParams.Add("start", params.Start) - queryParams.Add("end", params.End) - queryParams.Add("page", strconv.Itoa(params.Page)) - queryParams.Add("page-size", strconv.Itoa(params.PageSize)) - queryParams.Add("hydrated", strconv.FormatBool(params.Hydrated)) - queryParams.Add("in-progress", strconv.FormatBool(params.InProgress)) - worklogURL.RawQuery = queryParams.Encode() - - return fmt.Sprintf("%s?%s", worklogURL.Path, worklogURL.Query().Encode()), nil -} - -func (c *clockifyClient) fetchEntries(ctx context.Context, path string) ([]FetchEntry, error) { - resp, err := client.SendRequest(ctx, &client.SendRequestOpts{ - Method: http.MethodGet, - Path: path, - ClientOpts: &c.opts.HTTPClientOpts, - Data: nil, - }) - - if err != nil { - return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) - } - - var fetchedEntries []FetchEntry - if err = json.NewDecoder(resp.Body).Decode(&fetchedEntries); err != nil { - return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) - } - - return fetchedEntries, err -} - -func (c *clockifyClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRegex *regexp.Regexp) []worklog.Entry { - var entries []worklog.Entry +func (c *clockifyClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRegex *regexp.Regexp) worklog.Entries { + var entries worklog.Entries for _, entry := range fetchedEntries { billableDuration := entry.TimeInterval.End.Sub(entry.TimeInterval.Start) @@ -140,7 +105,7 @@ func (c *clockifyClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRe UnbillableDuration: unbillableDuration, } - if c.opts.TagsAsTasks && len(entry.Tags) > 0 { + if c.TagsAsTasks && len(entry.Tags) > 0 { pageEntries := worklogEntry.SplitByTagsAsTasks(entry.Description, tagsAsTasksRegex, entry.Tags) entries = append(entries, pageEntries...) } else { @@ -151,15 +116,35 @@ func (c *clockifyClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRe return entries } -func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) { +func (c *clockifyClient) fetchEntries(ctx context.Context, searchURL string) ([]FetchEntry, error) { + resp, err := c.Call(ctx, &client.HTTPRequestOpts{ + Method: http.MethodGet, + Url: searchURL, + Auth: c.authenticator, + Timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + + var fetchedEntries []FetchEntry + if err = json.Unmarshal(resp, &fetchedEntries); err != nil { + return nil, err + } + + return fetchedEntries, nil +} + +func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { var err error - var entries []worklog.Entry + var entries worklog.Entries currentPage := 1 pageSize := 100 var tagsAsTasksRegex *regexp.Regexp - if c.opts.TagsAsTasks { - tagsAsTasksRegex, err = regexp.Compile(c.opts.TagsAsTasksRegex) + if c.TagsAsTasks { + tagsAsTasksRegex, err = regexp.Compile(c.TagsAsTasksRegex) if err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } @@ -167,16 +152,14 @@ func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpt // Naive pagination as the API does not return the number of total entries for currentPage*pageSize < MaxPageLength { - searchParams := &WorklogSearchParams{ - Start: opts.Start.Format(DateFormat), - End: opts.End.Format(DateFormat), - Page: currentPage, - PageSize: pageSize, - Hydrated: true, - InProgress: false, - } - - searchURL, err := c.getSearchURL(opts.User, searchParams) + searchURL, err := c.URL(fmt.Sprintf(PathWorklog, c.workspace, opts.User), map[string]string{ + "start": utils.DateFormatRFC3339UTC.Format(opts.Start.Local()), + "end": utils.DateFormatRFC3339UTC.Format(opts.End.Local()), + "page": strconv.Itoa(currentPage), + "page-size": strconv.Itoa(pageSize), + "hydrated": strconv.FormatBool(true), + "in-progress": strconv.FormatBool(false), + }) if err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } @@ -198,9 +181,22 @@ func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpt return entries, nil } -// NewClient returns a new Clockify client. -func NewClient(opts *ClientOpts) client.Fetcher { - return &clockifyClient{ - opts: opts, +// 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.Token) + if err != nil { + return nil, err } + + return &clockifyClient{ + authenticator: authenticator, + HTTPClient: &client.HTTPClient{BaseURL: baseURL}, + BaseClientOpts: &opts.BaseClientOpts, + workspace: opts.Workspace, + }, nil } diff --git a/internal/pkg/client/clockify/clockify_test.go b/internal/pkg/client/clockify/clockify_test.go index 6bcfc19..b85ec00 100644 --- a/internal/pkg/client/clockify/clockify_test.go +++ b/internal/pkg/client/clockify/clockify_test.go @@ -62,7 +62,7 @@ func TestClockifyClient_FetchEntries(t *testing.T) { end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) remainingCalls := 1 - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "456", @@ -187,20 +187,17 @@ func TestClockifyClient_FetchEntries(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Token: "t-o-k-e-n", - TokenHeader: "X-Api-Key", - } - - clockifyClient := clockify.NewClient(&clockify.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, + clockifyClient, err := clockify.NewFetcher(&clockify.ClientOpts{ + TokenAuth: client.TokenAuth{ + Header: "X-Api-Key", + Token: "t-o-k-e-n", }, + BaseURL: mockServer.URL, Workspace: "marvel-studios", }) + require.Nil(t, err) + entries, err := clockifyClient.FetchEntries(context.Background(), &client.FetchOpts{ User: "steve-rogers", Start: start, @@ -216,7 +213,7 @@ func TestClockifyClient_FetchEntries_TasksAsTags(t *testing.T) { end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) remainingCalls := 1 - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "456", @@ -354,22 +351,21 @@ func TestClockifyClient_FetchEntries_TasksAsTags(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Token: "t-o-k-e-n", - TokenHeader: "X-Api-Key", - } - - clockifyClient := clockify.NewClient(&clockify.ClientOpts{ + clockifyClient, err := clockify.NewFetcher(&clockify.ClientOpts{ BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, TagsAsTasks: true, TagsAsTasksRegex: `^TASK\-\d+$`, }, + TokenAuth: client.TokenAuth{ + Header: "X-Api-Key", + Token: "t-o-k-e-n", + }, + BaseURL: mockServer.URL, Workspace: "marvel-studios", }) + require.Nil(t, err) + entries, err := clockifyClient.FetchEntries(context.Background(), &client.FetchOpts{ User: "steve-rogers", Start: start, diff --git a/internal/pkg/client/fetcher.go b/internal/pkg/client/fetcher.go new file mode 100644 index 0000000..2beefef --- /dev/null +++ b/internal/pkg/client/fetcher.go @@ -0,0 +1,42 @@ +package client + +import ( + "context" + "errors" + "time" + + "github.com/gabor-boros/minutes/internal/pkg/worklog" +) + +const ( + // DefaultPageSize used by paginated fetchers setting the fetched page size. + // The minimum page sizes can be different per client, but the 50 items per + // page is usually supported everywhere. + DefaultPageSize int = 50 + // DefaultMaxPageSize used by paginated fetchers setting the maximum entries + // per page. The maximum page sizes can be different per client, but the + // 250 items per page is usually supported everywhere. + DefaultMaxPageSize int = 250 +) + +var ( + // ErrFetchEntries wraps the error when fetch failed. + ErrFetchEntries = errors.New("failed to fetch entries") +) + +// FetchOpts specifies the only options for Fetchers. +// In contract to the BaseClientOpts, these options shall not be extended or +// overridden. +type FetchOpts struct { + User string + Start time.Time + End time.Time +} + +// Fetcher specifies the functions used to fetch worklog entries. +type Fetcher interface { + // FetchEntries from a given source and return the list of worklog entries + // If the fetching resulted in an error, the list of worklog entries will be + // nil and an error will return. + FetchEntries(ctx context.Context, opts *FetchOpts) (worklog.Entries, error) +} diff --git a/internal/pkg/client/tempo/tempo.go b/internal/pkg/client/tempo/tempo.go index 8772977..8a9105b 100644 --- a/internal/pkg/client/tempo/tempo.go +++ b/internal/pkg/client/tempo/tempo.go @@ -6,12 +6,12 @@ import ( "fmt" "math" "net/http" + "net/url" "strconv" "time" - "github.com/jedib0t/go-pretty/v6/progress" - "github.com/gabor-boros/minutes/internal/pkg/client" + "github.com/gabor-boros/minutes/internal/pkg/utils" "github.com/gabor-boros/minutes/internal/pkg/worklog" ) @@ -67,24 +67,36 @@ type SearchParams struct { // ClientOpts is the client specific options, extending client.BaseClientOpts. type ClientOpts struct { client.BaseClientOpts + client.BasicAuth + BaseURL string } type tempoClient struct { - opts *ClientOpts + *client.BaseClientOpts + *client.HTTPClient + *client.DefaultUploader + authenticator client.Authenticator } -func (c *tempoClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) { - searchParams := &SearchParams{ - From: opts.Start.Local().Format("2006-01-02"), - To: opts.End.Local().Format("2006-01-02"), - Worker: opts.User, +func (c *tempoClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { + searchURL, err := c.URL(PathWorklogSearch, map[string]string{}) + if err != nil { + return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } - resp, err := client.SendRequest(ctx, &client.SendRequestOpts{ - Method: http.MethodPost, - Path: PathWorklogSearch, - ClientOpts: &c.opts.HTTPClientOpts, - Data: searchParams, + resp, err := c.Call(ctx, &client.HTTPRequestOpts{ + Method: http.MethodPost, + Url: searchURL, + Auth: c.authenticator, + Timeout: c.Timeout, + Data: &SearchParams{ + From: utils.DateFormatISO8601.Format(opts.Start.Local()), + To: utils.DateFormatISO8601.Format(opts.End.Local()), + Worker: opts.User, + }, + Headers: map[string]string{ + "Content-Type": "application/json", + }, }) if err != nil { @@ -92,11 +104,11 @@ func (c *tempoClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) } var fetchedEntries []FetchEntry - if err = json.NewDecoder(resp.Body).Decode(&fetchedEntries); err != nil { + if err = json.Unmarshal(resp, &fetchedEntries); err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } - var entries []worklog.Entry + var entries worklog.Entries for _, entry := range fetchedEntries { entries = append(entries, worklog.Entry{ Client: worklog.IDNameField{ @@ -122,86 +134,89 @@ func (c *tempoClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) return entries, nil } -func (c *tempoClient) uploadEntry(ctx context.Context, entries []worklog.Entry, opts *client.UploadOpts, errChan chan error) { - for _, entry := range entries { - var tracker *progress.Tracker - if opts.ProgressWriter != nil { - tracker = &progress.Tracker{ - Message: entry.Summary, - Total: 1, - Units: progress.UnitsDefault, - } - - opts.ProgressWriter.AppendTracker(tracker) - } - - billableDuration := entry.BillableDuration - unbillableDuration := entry.UnbillableDuration - totalTimeSpent := billableDuration + unbillableDuration - - if opts.TreatDurationAsBilled { - billableDuration = entry.UnbillableDuration + entry.BillableDuration - unbillableDuration = 0 - } - - if opts.RoundToClosestMinute { - billableDuration = time.Second * time.Duration(math.Round(billableDuration.Minutes())*60) - unbillableDuration = time.Second * time.Duration(math.Round(unbillableDuration.Minutes())*60) - totalTimeSpent = billableDuration + unbillableDuration - } - - uploadEntry := &UploadEntry{ - Comment: entry.Summary, - IncludeNonWorkingDays: true, - OriginTaskID: entry.Task.Name, - Started: entry.Start.Local().Format("2006-01-02"), - BillableSeconds: int(billableDuration.Seconds()), - TimeSpentSeconds: int(totalTimeSpent.Seconds()), - Worker: opts.User, - } - - _, err := client.SendRequest(ctx, &client.SendRequestOpts{ - Method: http.MethodPost, - Path: PathWorklogCreate, - ClientOpts: &c.opts.HTTPClientOpts, - Data: uploadEntry, - }) +func (c *tempoClient) UploadEntries(ctx context.Context, entries worklog.Entries, errChan chan error, opts *client.UploadOpts) { + createURL, err := c.URL(PathWorklogCreate, map[string]string{}) + if err != nil { + errChan <- fmt.Errorf("%v: %v", client.ErrUploadEntries, err) + return + } - if err != nil { - if tracker != nil { - tracker.MarkAsErrored() + for _, groupEntries := range entries.GroupByTask() { + go func(ctx context.Context, entries worklog.Entries, errChan chan error, opts *client.UploadOpts) { + for _, entry := range entries { + billableDuration := entry.BillableDuration + unbillableDuration := entry.UnbillableDuration + totalTimeSpent := billableDuration + unbillableDuration + + if opts.TreatDurationAsBilled { + billableDuration = entry.UnbillableDuration + entry.BillableDuration + unbillableDuration = 0 + } + + if opts.RoundToClosestMinute { + billableDuration = time.Second * time.Duration(math.Round(billableDuration.Minutes())*60) + unbillableDuration = time.Second * time.Duration(math.Round(unbillableDuration.Minutes())*60) + totalTimeSpent = billableDuration + unbillableDuration + } + + uploadEntry := &UploadEntry{ + Comment: entry.Summary, + IncludeNonWorkingDays: true, + OriginTaskID: entry.Task.Name, + Started: utils.DateFormatISO8601.Format(entry.Start.Local()), + BillableSeconds: int(billableDuration.Seconds()), + TimeSpentSeconds: int(totalTimeSpent.Seconds()), + Worker: opts.User, + } + + tracker := c.StartTracking(entry, opts.ProgressWriter) + + _, err := c.Call(ctx, &client.HTTPRequestOpts{ + Method: http.MethodPost, + Url: createURL, + Auth: c.authenticator, + Timeout: c.Timeout, + Data: uploadEntry, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + }) + + if err != nil { + err = fmt.Errorf("%v: %+v: %v", client.ErrUploadEntries, uploadEntry, err) + } + + c.StopTracking(tracker, err) + errChan <- err } - - errChan <- fmt.Errorf("%v: %+v: %v", client.ErrUploadEntries, uploadEntry, err) - return - } - - if tracker != nil { - tracker.Increment(1) - tracker.MarkAsDone() - } - - errChan <- nil + }(ctx, groupEntries, errChan, opts) } } -func (c *tempoClient) UploadEntries(ctx context.Context, entries []worklog.Entry, errChan chan error, opts *client.UploadOpts) { - uploadGroups := map[string][]worklog.Entry{} - - for _, entry := range entries { - key := entry.Task.ID - groupEntries := uploadGroups[key] - uploadGroups[key] = append(groupEntries, entry) +func newClient(opts *ClientOpts) (*tempoClient, error) { + baseURL, err := url.Parse(opts.BaseURL) + if err != nil { + return nil, err } - for _, groupEntries := range uploadGroups { - go c.uploadEntry(ctx, groupEntries, opts, errChan) + authenticator, err := client.NewBasicAuth(opts.Username, opts.Password) + if err != nil { + return nil, err } -} -// NewClient returns a new Tempo client. -func NewClient(opts *ClientOpts) client.FetchUploader { return &tempoClient{ - opts: opts, - } + authenticator: authenticator, + HTTPClient: &client.HTTPClient{BaseURL: baseURL}, + BaseClientOpts: &opts.BaseClientOpts, + }, nil +} + +// NewFetcher returns a new Tempo client for fetching entries. +func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { + return newClient(opts) +} + +// NewUploader returns a new Tempo client for uploading entries. +func NewUploader(opts *ClientOpts) (client.Uploader, error) { + return newClient(opts) } diff --git a/internal/pkg/client/tempo/tempo_test.go b/internal/pkg/client/tempo/tempo_test.go index af8e6b0..87efbcb 100644 --- a/internal/pkg/client/tempo/tempo_test.go +++ b/internal/pkg/client/tempo/tempo_test.go @@ -10,11 +10,12 @@ import ( "testing" "time" - "github.com/gabor-boros/minutes/internal/cmd/utils" + cmdUtils "github.com/gabor-boros/minutes/internal/cmd/utils" "github.com/jedib0t/go-pretty/v6/progress" "github.com/gabor-boros/minutes/internal/pkg/client" "github.com/gabor-boros/minutes/internal/pkg/client/tempo" + "github.com/gabor-boros/minutes/internal/pkg/utils" "github.com/gabor-boros/minutes/internal/pkg/worklog" "github.com/stretchr/testify/require" ) @@ -56,6 +57,10 @@ func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { require.Equal(t, e.Password, password, "API call basic auth password mismatch") } + if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { + require.Failf(t, "Content-Type mismatch, want: %s, got: %s", "application/json", contentType) + } + if e.RequestData != nil { var data interface{} @@ -112,7 +117,7 @@ func TestTempoClient_FetchEntries(t *testing.T) { clientUsername := "Thor" clientPassword := "The strongest Avenger" - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "My Awesome Company", @@ -179,8 +184,8 @@ func TestTempoClient_FetchEntries(t *testing.T) { Username: clientUsername, Password: clientPassword, RequestData: &tempo.SearchParams{ - From: start.Format("2006-01-02"), - To: end.Format("2006-01-02"), + From: utils.DateFormatISO8601.Format(start), + To: utils.DateFormatISO8601.Format(end), Worker: "steve-rogers", }, ResponseData: &[]tempo.FetchEntry{ @@ -236,18 +241,14 @@ func TestTempoClient_FetchEntries(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: clientUsername, - Password: clientPassword, - } - - tempoClient := tempo.NewClient(&tempo.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, + tempoClient, err := tempo.NewFetcher(&tempo.ClientOpts{ + BasicAuth: client.BasicAuth{ + Username: clientUsername, + Password: clientPassword, }, + BaseURL: mockServer.URL, }) + require.Nil(t, err) entries, err := tempoClient.FetchEntries(context.Background(), &client.FetchOpts{ User: "steve-rogers", @@ -265,13 +266,13 @@ func TestTempoClient_UploadEntries(t *testing.T) { clientUsername := "Thor" clientPassword := "The strongest Avenger" - progressWriter := utils.NewProgressWriter(progress.DefaultUpdateFrequency) + progressWriter := cmdUtils.NewProgressWriter(progress.DefaultUpdateFrequency) uploadOpts := &client.UploadOpts{ User: "steve-rogers", ProgressWriter: progressWriter, } - entries := []worklog.Entry{ + entries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "My Awesome Company", @@ -318,7 +319,7 @@ func TestTempoClient_UploadEntries(t *testing.T) { Comment: entry.Notes, IncludeNonWorkingDays: true, OriginTaskID: entry.Task.ID, - Started: entry.Start.Local().Format("2006-01-02"), + Started: utils.DateFormatISO8601.Format(entry.Start.Local()), BillableSeconds: int(entry.BillableDuration.Seconds()), TimeSpentSeconds: int((entry.BillableDuration + entry.UnbillableDuration).Seconds()), Worker: uploadOpts.User, @@ -335,18 +336,14 @@ func TestTempoClient_UploadEntries(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: clientUsername, - Password: clientPassword, - } - - tempoClient := tempo.NewClient(&tempo.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, + tempoClient, err := tempo.NewUploader(&tempo.ClientOpts{ + BasicAuth: client.BasicAuth{ + Username: clientUsername, + Password: clientPassword, }, + BaseURL: mockServer.URL, }) + require.Nil(t, err) errChan := make(chan error) tempoClient.UploadEntries(context.Background(), entries, errChan, uploadOpts) @@ -369,7 +366,7 @@ func TestTempoClient_UploadEntries_TreatDurationAsBilled(t *testing.T) { TreatDurationAsBilled: true, } - entries := []worklog.Entry{ + entries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "My Awesome Company", @@ -433,18 +430,14 @@ func TestTempoClient_UploadEntries_TreatDurationAsBilled(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: clientUsername, - Password: clientPassword, - } - - tempoClient := tempo.NewClient(&tempo.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, + tempoClient, err := tempo.NewUploader(&tempo.ClientOpts{ + BasicAuth: client.BasicAuth{ + Username: clientUsername, + Password: clientPassword, }, + BaseURL: mockServer.URL, }) + require.Nil(t, err) errChan := make(chan error) tempoClient.UploadEntries(context.Background(), entries, errChan, uploadOpts) @@ -463,7 +456,7 @@ func TestTempoClient_UploadEntries_RoundToClosestMinute(t *testing.T) { RoundToClosestMinute: true, } - entries := []worklog.Entry{ + entries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "My Awesome Company", @@ -547,7 +540,7 @@ func TestTempoClient_UploadEntries_RoundToClosestMinute(t *testing.T) { Comment: entries[0].Notes, IncludeNonWorkingDays: true, OriginTaskID: entries[0].Task.ID, - Started: entries[0].Start.Local().Format("2006-01-02"), + Started: utils.DateFormatISO8601.Format(entries[0].Start.Local()), BillableSeconds: 60, TimeSpentSeconds: 60, Worker: uploadOpts.User, @@ -556,7 +549,7 @@ func TestTempoClient_UploadEntries_RoundToClosestMinute(t *testing.T) { Comment: entries[1].Notes, IncludeNonWorkingDays: true, OriginTaskID: entries[1].Task.ID, - Started: entries[1].Start.Local().Format("2006-01-02"), + Started: utils.DateFormatISO8601.Format(entries[1].Start.Local()), BillableSeconds: 0, TimeSpentSeconds: 0, Worker: uploadOpts.User, @@ -565,7 +558,7 @@ func TestTempoClient_UploadEntries_RoundToClosestMinute(t *testing.T) { Comment: entries[2].Notes, IncludeNonWorkingDays: true, OriginTaskID: entries[2].Task.ID, - Started: entries[2].Start.Local().Format("2006-01-02"), + Started: utils.DateFormatISO8601.Format(entries[2].Start.Local()), BillableSeconds: 1, TimeSpentSeconds: 60, Worker: uploadOpts.User, @@ -574,7 +567,7 @@ func TestTempoClient_UploadEntries_RoundToClosestMinute(t *testing.T) { Comment: entries[3].Notes, IncludeNonWorkingDays: true, OriginTaskID: entries[3].Task.ID, - Started: entries[3].Start.Local().Format("2006-01-02"), + Started: utils.DateFormatISO8601.Format(entries[3].Start.Local()), BillableSeconds: 0, TimeSpentSeconds: 60, Worker: uploadOpts.User, @@ -591,18 +584,14 @@ func TestTempoClient_UploadEntries_RoundToClosestMinute(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: clientUsername, - Password: clientPassword, - } - - tempoClient := tempo.NewClient(&tempo.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, + tempoClient, err := tempo.NewUploader(&tempo.ClientOpts{ + BasicAuth: client.BasicAuth{ + Username: clientUsername, + Password: clientPassword, }, + BaseURL: mockServer.URL, }) + require.Nil(t, err) errChan := make(chan error) tempoClient.UploadEntries(context.Background(), entries, errChan, uploadOpts) diff --git a/internal/pkg/client/timewarrior/timewarrior.go b/internal/pkg/client/timewarrior/timewarrior.go index 091b9f3..e001c09 100644 --- a/internal/pkg/client/timewarrior/timewarrior.go +++ b/internal/pkg/client/timewarrior/timewarrior.go @@ -4,19 +4,14 @@ import ( "context" "encoding/json" "fmt" - "os/exec" "regexp" "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 ( - dateFormat string = "2006-01-02T15:04:05" - ParseDateFormat string = "20060102T150405Z" -) - // FetchEntry represents the entry exported from Timewarrior. type FetchEntry struct { ID int `json:"id"` @@ -34,55 +29,30 @@ type FetchEntry struct { // (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 + client.CLIClient + UnbillableTag string + ClientTagRegex string + ProjectTagRegex string } type timewarriorClient struct { - opts *ClientOpts + *client.BaseClientOpts + *client.CLIClient clientTagRegex *regexp.Regexp projectTagRegex *regexp.Regexp tagsAsTasksRegex *regexp.Regexp + unbillableTag string } -func (c *timewarriorClient) executeCommand(ctx context.Context, subcommand string, entries *[]FetchEntry, opts *client.FetchOpts) error { - arguments := []string{subcommand} - - arguments = append( - arguments, - []string{ - "from", opts.Start.Format(dateFormat), - "to", opts.End.Format(dateFormat), - }..., - ) - - arguments = append(arguments, c.opts.CommandArguments...) - - out, err := c.opts.CommandCtxExecutor(ctx, c.opts.Command, arguments...).Output() // #nosec G204 - if err != nil { - return err - } - - if err = json.Unmarshal(out, &entries); err != nil { - return err - } - - return nil -} - -func (c *timewarriorClient) parseEntry(entry FetchEntry) ([]worklog.Entry, error) { - var entries []worklog.Entry +func (c *timewarriorClient) parseEntry(entry FetchEntry) (worklog.Entries, error) { + var entries worklog.Entries - startDate, err := time.ParseInLocation(ParseDateFormat, entry.Start, time.Local) + startDate, err := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), entry.Start, time.Local) if err != nil { return nil, err } - endDate, err := time.ParseInLocation(ParseDateFormat, entry.End, time.Local) + endDate, err := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), entry.End, time.Local) if err != nil { return nil, err } @@ -96,20 +66,20 @@ func (c *timewarriorClient) parseEntry(entry FetchEntry) ([]worklog.Entry, error } for _, tag := range entry.Tags { - if tag == c.opts.UnbillableTag { + if tag == c.unbillableTag { worklogEntry.UnbillableDuration = worklogEntry.BillableDuration worklogEntry.BillableDuration = 0 - } else if c.opts.ClientTagRegex != "" && c.clientTagRegex.MatchString(tag) { + } else if c.clientTagRegex.String() != "" && c.clientTagRegex.MatchString(tag) { worklogEntry.Client = worklog.IDNameField{ ID: tag, Name: tag, } - } else if c.opts.ProjectTagRegex != "" && c.projectTagRegex.MatchString(tag) { + } else if c.projectTagRegex.String() != "" && c.projectTagRegex.MatchString(tag) { worklogEntry.Project = worklog.IDNameField{ ID: tag, Name: tag, } - } else if c.opts.TagsAsTasksRegex != "" && c.tagsAsTasksRegex.MatchString(tag) { + } else if c.tagsAsTasksRegex.String() != "" && c.tagsAsTasksRegex.MatchString(tag) { worklogEntry.Task = worklog.IDNameField{ ID: tag, Name: tag, @@ -125,7 +95,7 @@ func (c *timewarriorClient) parseEntry(entry FetchEntry) ([]worklog.Entry, error } } - if c.opts.TagsAsTasks && len(entry.Tags) > 0 { + if c.TagsAsTasks && len(entry.Tags) > 0 { var tags []worklog.IDNameField for _, tag := range entry.Tags { tags = append(tags, worklog.IDNameField{ @@ -143,13 +113,41 @@ func (c *timewarriorClient) parseEntry(entry FetchEntry) ([]worklog.Entry, error return entries, nil } -func (c *timewarriorClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) { +func (c *timewarriorClient) executeCommand(ctx context.Context, subcommand string, entries *[]FetchEntry, opts *client.FetchOpts) error { + arguments := []string{subcommand} + + arguments = append( + arguments, + []string{ + "from", utils.DateFormatRFC3339Local.Format(opts.Start), + "to", utils.DateFormatRFC3339Local.Format(opts.End), + }..., + ) + + arguments = append(arguments, c.CommandArguments...) + + out, err := c.Execute(ctx, arguments, &client.CLIExecuteOpts{ + Timeout: c.Timeout, + }) + + if err != nil { + return err + } + + if err = json.Unmarshal(out, &entries); err != nil { + return err + } + + return nil +} + +func (c *timewarriorClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { var fetchedEntries []FetchEntry if err := c.executeCommand(ctx, "export", &fetchedEntries, opts); err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } - var entries []worklog.Entry + var entries worklog.Entries for _, entry := range fetchedEntries { parsedEntries, err := c.parseEntry(entry) if err != nil { @@ -162,8 +160,8 @@ func (c *timewarriorClient) FetchEntries(ctx context.Context, opts *client.Fetch return entries, nil } -// NewClient returns a new Timewarrior client. -func NewClient(opts *ClientOpts) (client.Fetcher, error) { +// NewFetcher returns a new Timewarrior client for fetching entries. +func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { clientTagRegex, err := regexp.Compile(opts.ClientTagRegex) if err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) @@ -180,7 +178,9 @@ func NewClient(opts *ClientOpts) (client.Fetcher, error) { } return &timewarriorClient{ - opts: opts, + BaseClientOpts: &opts.BaseClientOpts, + CLIClient: &opts.CLIClient, + unbillableTag: opts.UnbillableTag, clientTagRegex: clientTagRegex, projectTagRegex: projectTagRegex, tagsAsTasksRegex: tagsAsTasksRegex, diff --git a/internal/pkg/client/timewarrior/timewarrior_test.go b/internal/pkg/client/timewarrior/timewarrior_test.go index 19993d1..99ee154 100644 --- a/internal/pkg/client/timewarrior/timewarrior_test.go +++ b/internal/pkg/client/timewarrior/timewarrior_test.go @@ -11,6 +11,7 @@ import ( "github.com/gabor-boros/minutes/internal/pkg/client" "github.com/gabor-boros/minutes/internal/pkg/client/timewarrior" + "github.com/gabor-boros/minutes/internal/pkg/utils" "github.com/gabor-boros/minutes/internal/pkg/worklog" "github.com/stretchr/testify/require" ) @@ -47,8 +48,8 @@ func TestExecCommandHelper(t *testing.T) { } func TestTimewarriorClient_FetchEntries(t *testing.T) { - start, _ := time.ParseInLocation(timewarrior.ParseDateFormat, "20211012T054408Z", time.Local) - end, _ := time.ParseInLocation(timewarrior.ParseDateFormat, "20211012T054420Z", time.Local) + start, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054408Z", time.Local) + end, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054420Z", time.Local) mockedExitCode = 0 mockedStdout = `[ @@ -57,7 +58,7 @@ func TestTimewarriorClient_FetchEntries(t *testing.T) { {"id":1,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","TASK-456","project","client","unbillable"],"annotation":"working unbilled"} ]` - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "otherclient", @@ -117,14 +118,16 @@ func TestTimewarriorClient_FetchEntries(t *testing.T) { }, } - timewarriorClient, err := timewarrior.NewClient(&timewarrior.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{}, - Command: "timewarrior-command", - CommandArguments: []string{}, - CommandCtxExecutor: mockedExecCommand, - UnbillableTag: "unbillable", - ClientTagRegex: "^(client|otherclient)$", - ProjectTagRegex: "^(project)$", + timewarriorClient, err := timewarrior.NewFetcher(&timewarrior.ClientOpts{ + BaseClientOpts: client.BaseClientOpts{}, + CLIClient: client.CLIClient{ + Command: "timewarrior-command", + CommandArguments: []string{}, + CommandCtxExecutor: mockedExecCommand, + }, + UnbillableTag: "unbillable", + ClientTagRegex: "^(client|otherclient)$", + ProjectTagRegex: "^(project)$", }) require.Nil(t, err) @@ -139,8 +142,8 @@ func TestTimewarriorClient_FetchEntries(t *testing.T) { } func TestTimewarriorClient_FetchEntries_TagsAsTasksRegex_NoSplit(t *testing.T) { - start, _ := time.ParseInLocation(timewarrior.ParseDateFormat, "20211012T054408Z", time.Local) - end, _ := time.ParseInLocation(timewarrior.ParseDateFormat, "20211012T054420Z", time.Local) + start, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054408Z", time.Local) + end, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054420Z", time.Local) mockedExitCode = 0 mockedStdout = `[ @@ -149,7 +152,7 @@ func TestTimewarriorClient_FetchEntries_TagsAsTasksRegex_NoSplit(t *testing.T) { {"id":1,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","TASK-456","project","client","unbillable"],"annotation":"working unbilled"} ]` - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "otherclient", @@ -209,17 +212,19 @@ func TestTimewarriorClient_FetchEntries_TagsAsTasksRegex_NoSplit(t *testing.T) { }, } - timewarriorClient, err := timewarrior.NewClient(&timewarrior.ClientOpts{ + timewarriorClient, err := timewarrior.NewFetcher(&timewarrior.ClientOpts{ BaseClientOpts: client.BaseClientOpts{ TagsAsTasks: false, TagsAsTasksRegex: `^TASK\-\d+$`, }, - Command: "timewarrior-command", - CommandArguments: []string{}, - CommandCtxExecutor: mockedExecCommand, - UnbillableTag: "unbillable", - ClientTagRegex: "^(client|otherclient)$", - ProjectTagRegex: "^(project)$", + CLIClient: client.CLIClient{ + Command: "timewarrior-command", + CommandArguments: []string{}, + CommandCtxExecutor: mockedExecCommand, + }, + UnbillableTag: "unbillable", + ClientTagRegex: "^(client|otherclient)$", + ProjectTagRegex: "^(project)$", }) require.Nil(t, err) @@ -234,8 +239,8 @@ func TestTimewarriorClient_FetchEntries_TagsAsTasksRegex_NoSplit(t *testing.T) { } func TestTimewarriorClient_FetchEntries_TagsAsTasks(t *testing.T) { - start, _ := time.ParseInLocation(timewarrior.ParseDateFormat, "20211012T054408Z", time.Local) - end, _ := time.ParseInLocation(timewarrior.ParseDateFormat, "20211012T054420Z", time.Local) + start, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054408Z", time.Local) + end, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054420Z", time.Local) mockedExitCode = 0 mockedStdout = `[ @@ -244,7 +249,7 @@ func TestTimewarriorClient_FetchEntries_TagsAsTasks(t *testing.T) { {"id":1,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","TASK-456","project","client","unbillable"],"annotation":"working unbilled split"} ]` - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "otherclient", @@ -323,17 +328,19 @@ func TestTimewarriorClient_FetchEntries_TagsAsTasks(t *testing.T) { }, } - timewarriorClient, err := timewarrior.NewClient(&timewarrior.ClientOpts{ + timewarriorClient, err := timewarrior.NewFetcher(&timewarrior.ClientOpts{ BaseClientOpts: client.BaseClientOpts{ TagsAsTasks: true, TagsAsTasksRegex: `^TASK\-\d+$`, }, - Command: "timewarrior-command", - CommandArguments: []string{}, - CommandCtxExecutor: mockedExecCommand, - UnbillableTag: "unbillable", - ClientTagRegex: "^(client|otherclient)$", - ProjectTagRegex: "^(project)$", + CLIClient: client.CLIClient{ + Command: "timewarrior-command", + CommandArguments: []string{}, + CommandCtxExecutor: mockedExecCommand, + }, + UnbillableTag: "unbillable", + ClientTagRegex: "^(client|otherclient)$", + ProjectTagRegex: "^(project)$", }) require.Nil(t, err) diff --git a/internal/pkg/client/toggl/toggl.go b/internal/pkg/client/toggl/toggl.go index 13471b8..6f17fc7 100644 --- a/internal/pkg/client/toggl/toggl.go +++ b/internal/pkg/client/toggl/toggl.go @@ -12,12 +12,11 @@ import ( "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 ( - // DateFormat is the ISO 8601 format used by Toggl to parse time. - DateFormat string = "2006-01-02" // PathWorklog is the endpoint used to search existing worklogs. PathWorklog string = "/reports/api/v2/details" ) @@ -45,45 +44,23 @@ type PaginatedResponse struct { Data []FetchEntry `json:"data"` } -// WorklogSearchParams represents the parameters used to filter search results. -type WorklogSearchParams struct { - Since string - Until string - Page int - UserID int - WorkspaceID int -} - // ClientOpts is the client specific options, extending client.BaseClientOpts. type ClientOpts struct { client.BaseClientOpts + client.BasicAuth + BaseURL string Workspace int } type togglClient struct { - opts *ClientOpts -} - -func (c *togglClient) getSearchURL(params *WorklogSearchParams) (string, error) { - worklogURL, err := url.Parse(c.opts.BaseURL + PathWorklog) - if err != nil { - return "", err - } - - queryParams := worklogURL.Query() - queryParams.Add("since", params.Since) - queryParams.Add("until", params.Until) - queryParams.Add("page", strconv.Itoa(params.Page)) - queryParams.Add("user_id", strconv.Itoa(params.UserID)) - queryParams.Add("workspace_id", strconv.Itoa(params.WorkspaceID)) - queryParams.Add("user_agent", "github.com/gabor-boros/minutes") - worklogURL.RawQuery = queryParams.Encode() - - return fmt.Sprintf("%s?%s", worklogURL.Path, worklogURL.Query().Encode()), nil + *client.BaseClientOpts + *client.HTTPClient + authenticator client.Authenticator + workspace int } -func (c *togglClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRegex *regexp.Regexp) ([]worklog.Entry, error) { - var entries []worklog.Entry +func (c *togglClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRegex *regexp.Regexp) (worklog.Entries, error) { + var entries worklog.Entries for _, fetchedEntry := range fetchedEntries { billableDuration := time.Millisecond * time.Duration(fetchedEntry.Duration) @@ -114,7 +91,7 @@ func (c *togglClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRegex UnbillableDuration: unbillableDuration, } - if c.opts.TagsAsTasks && len(fetchedEntry.Tags) > 0 { + if c.TagsAsTasks && len(fetchedEntry.Tags) > 0 { var tags []worklog.IDNameField for _, tag := range fetchedEntry.Tags { tags = append(tags, worklog.IDNameField{ @@ -133,12 +110,12 @@ func (c *togglClient) parseEntries(fetchedEntries []FetchEntry, tagsAsTasksRegex return entries, nil } -func (c *togglClient) fetchEntries(ctx context.Context, path string, tagsAsTasksRegex *regexp.Regexp) ([]worklog.Entry, *PaginatedResponse, error) { - resp, err := client.SendRequest(ctx, &client.SendRequestOpts{ - Method: http.MethodGet, - Path: path, - ClientOpts: &c.opts.HTTPClientOpts, - Data: nil, +func (c *togglClient) fetchEntries(ctx context.Context, reqURL string, tagsAsTasksRegex *regexp.Regexp) (worklog.Entries, *PaginatedResponse, error) { + resp, err := c.Call(ctx, &client.HTTPRequestOpts{ + Method: http.MethodGet, + Url: reqURL, + Auth: c.authenticator, + Timeout: c.Timeout, }) if err != nil { @@ -146,7 +123,7 @@ func (c *togglClient) fetchEntries(ctx context.Context, path string, tagsAsTasks } var paginatedResponse PaginatedResponse - if err = json.NewDecoder(resp.Body).Decode(&paginatedResponse); err != nil { + if err = json.Unmarshal(resp, &paginatedResponse); err != nil { return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } @@ -158,13 +135,13 @@ func (c *togglClient) fetchEntries(ctx context.Context, path string, tagsAsTasks return parsedEntries, &paginatedResponse, err } -func (c *togglClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) { +func (c *togglClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { var err error - var entries []worklog.Entry + var entries worklog.Entries var tagsAsTasksRegex *regexp.Regexp - if c.opts.TagsAsTasks { - tagsAsTasksRegex, err = regexp.Compile(c.opts.TagsAsTasksRegex) + if c.TagsAsTasks { + tagsAsTasksRegex, err = regexp.Compile(c.TagsAsTasksRegex) if err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } @@ -180,15 +157,15 @@ func (c *togglClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) } for paginationNeeded { - searchParams := &WorklogSearchParams{ - Since: opts.Start.Format(DateFormat), - Until: opts.End.Format(DateFormat), - Page: currentPage, - UserID: userID, - WorkspaceID: c.opts.Workspace, - } + searchURL, err := c.URL(PathWorklog, map[string]string{ + "since": utils.DateFormatISO8601.Format(opts.Start), + "until": utils.DateFormatISO8601.Format(opts.End), + "page": strconv.Itoa(currentPage), + "user_id": strconv.Itoa(userID), + "workspace_id": strconv.Itoa(c.workspace), + "user_agent": "github.com/gabor-boros/minutes", + }) - searchURL, err := c.getSearchURL(searchParams) if err != nil { return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) } @@ -208,9 +185,22 @@ func (c *togglClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) return entries, nil } -// NewClient returns a new Toggl client. -func NewClient(opts *ClientOpts) client.Fetcher { - return &togglClient{ - opts: opts, +// NewFetcher returns a new Toggl 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.NewBasicAuth(opts.Username, opts.Password) + if err != nil { + return nil, err } + + return &togglClient{ + authenticator: authenticator, + HTTPClient: &client.HTTPClient{BaseURL: baseURL}, + BaseClientOpts: &opts.BaseClientOpts, + workspace: opts.Workspace, + }, nil } diff --git a/internal/pkg/client/toggl/toggl_test.go b/internal/pkg/client/toggl/toggl_test.go index e81cc7f..39f6275 100644 --- a/internal/pkg/client/toggl/toggl_test.go +++ b/internal/pkg/client/toggl/toggl_test.go @@ -12,6 +12,7 @@ import ( "github.com/gabor-boros/minutes/internal/pkg/client" "github.com/gabor-boros/minutes/internal/pkg/client/toggl" + "github.com/gabor-boros/minutes/internal/pkg/utils" "github.com/gabor-boros/minutes/internal/pkg/worklog" "github.com/stretchr/testify/require" ) @@ -60,7 +61,7 @@ func TestTogglClient_FetchEntries(t *testing.T) { clientUsername := "token-of-the-day" clientPassword := "api_token" - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "My Awesome Company", @@ -105,8 +106,8 @@ func TestTogglClient_FetchEntries(t *testing.T) { Path: toggl.PathWorklog, QueryParams: url.Values{ "page": {"1"}, - "since": {start.Format(toggl.DateFormat)}, - "until": {end.Format(toggl.DateFormat)}, + "since": {utils.DateFormatISO8601.Format(start)}, + "until": {utils.DateFormatISO8601.Format(end)}, "user_id": {"987654321"}, "workspace_id": {"123456789"}, "user_agent": {"github.com/gabor-boros/minutes"}, @@ -148,19 +149,15 @@ func TestTogglClient_FetchEntries(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: clientUsername, - Password: clientPassword, - } - - togglClient := toggl.NewClient(&toggl.ClientOpts{ - BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, + togglClient, err := toggl.NewFetcher(&toggl.ClientOpts{ + BasicAuth: client.BasicAuth{ + Username: clientUsername, + Password: clientPassword, }, + BaseURL: mockServer.URL, Workspace: 123456789, }) + require.Nil(t, err) entries, err := togglClient.FetchEntries(context.Background(), &client.FetchOpts{ User: "987654321", @@ -179,7 +176,7 @@ func TestTogglClient_FetchEntries_TagsAsTasks(t *testing.T) { clientUsername := "token-of-the-day" clientPassword := "api_token" - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: worklog.IDNameField{ ID: "My Awesome Company", @@ -243,8 +240,8 @@ func TestTogglClient_FetchEntries_TagsAsTasks(t *testing.T) { Path: toggl.PathWorklog, QueryParams: url.Values{ "page": {"1"}, - "since": {start.Format(toggl.DateFormat)}, - "until": {end.Format(toggl.DateFormat)}, + "since": {utils.DateFormatISO8601.Format(start)}, + "until": {utils.DateFormatISO8601.Format(end)}, "user_id": {"987654321"}, "workspace_id": {"123456789"}, "user_agent": {"github.com/gabor-boros/minutes"}, @@ -288,21 +285,19 @@ func TestTogglClient_FetchEntries_TagsAsTasks(t *testing.T) { }) defer mockServer.Close() - httpClientOpts := &client.HTTPClientOpts{ - HTTPClient: http.DefaultClient, - BaseURL: mockServer.URL, - Username: clientUsername, - Password: clientPassword, - } - - togglClient := toggl.NewClient(&toggl.ClientOpts{ + togglClient, err := toggl.NewFetcher(&toggl.ClientOpts{ BaseClientOpts: client.BaseClientOpts{ - HTTPClientOpts: *httpClientOpts, TagsAsTasks: true, TagsAsTasksRegex: `^CPT\-\w+$`, }, + BasicAuth: client.BasicAuth{ + Username: clientUsername, + Password: clientPassword, + }, + BaseURL: mockServer.URL, Workspace: 123456789, }) + require.Nil(t, err) entries, err := togglClient.FetchEntries(context.Background(), &client.FetchOpts{ User: "987654321", diff --git a/internal/pkg/client/uploader.go b/internal/pkg/client/uploader.go new file mode 100644 index 0000000..80cb5f2 --- /dev/null +++ b/internal/pkg/client/uploader.go @@ -0,0 +1,80 @@ +package client + +import ( + "context" + "errors" + + "github.com/gabor-boros/minutes/internal/pkg/worklog" + "github.com/jedib0t/go-pretty/v6/progress" +) + +var ( + // ErrUploadEntries wraps the error when upload failed. + ErrUploadEntries = errors.New("failed to upload entries") +) + +// UploadOpts specifies the only options for the Uploader. In contrast to the +// BaseClientOpts, these options shall not be extended or overridden. +type UploadOpts struct { + // RoundToClosestMinute indicates to round the billed and unbilled duration + // separately to the closest minute. + // If the elapsed time is 30 seconds or more, the closest minute is the + // next minute, otherwise the previous one. In case the previous minute is + // 0 (zero), then 0 (zero) will be used for the billed and/or unbilled + // duration. + RoundToClosestMinute bool + // TreatDurationAsBilled indicates to use every time spent as billed. + TreatDurationAsBilled bool + // CreateMissingResources indicates the need of resource creation if the + // resource is missing. + // In the case of some Uploader, the resources must exist to be able to + // use them by their ID or name. + CreateMissingResources bool + // User represents the user in which name the time log will be uploaded. + User string + // ProgressWriter represents a writer that tracks the upload progress. + // In case the ProgressWriter is nil, that means the upload progress should + // not be tracked, hence, that's not an error. + ProgressWriter progress.Writer +} + +// Uploader specifies the functions used to upload worklog entries. +type Uploader interface { + // UploadEntries to a given target. + // If the upload resulted in an error, the upload will stop and an error + // will return. + UploadEntries(ctx context.Context, entries worklog.Entries, errChan chan error, opts *UploadOpts) +} + +// DefaultUploader defines helper function to make entry upload easier +type DefaultUploader struct{} + +// StartTracking creates a progress tracker, appends to the progress writer, then +// returns the appended writer for later use. +func (u *DefaultUploader) StartTracking(entry worklog.Entry, writer progress.Writer) *progress.Tracker { + var tracker *progress.Tracker + + if writer != nil { + tracker = &progress.Tracker{ + Message: entry.Summary, + Total: 1, + Units: progress.UnitsDefault, + } + + writer.AppendTracker(tracker) + } + + return tracker +} + +func (u *DefaultUploader) StopTracking(tracker *progress.Tracker, err error) { + if tracker == nil { + return + } + + if err == nil { + tracker.MarkAsDone() + } else { + tracker.MarkAsErrored() + } +} diff --git a/internal/pkg/client/uploader_test.go b/internal/pkg/client/uploader_test.go new file mode 100644 index 0000000..21b89de --- /dev/null +++ b/internal/pkg/client/uploader_test.go @@ -0,0 +1,95 @@ +package client_test + +import ( + "errors" + "testing" + "time" + + "github.com/gabor-boros/minutes/internal/pkg/client" + "github.com/gabor-boros/minutes/internal/pkg/worklog" + "github.com/jedib0t/go-pretty/v6/progress" + "github.com/stretchr/testify/require" +) + +func getTestEntry() worklog.Entry { + start := time.Date(2021, 10, 2, 5, 0, 0, 0, time.UTC) + end := start.Add(time.Hour * 2) + + return worklog.Entry{ + Client: worklog.IDNameField{ + ID: "client-id", + Name: "My Awesome Company", + }, + Project: worklog.IDNameField{ + ID: "project-id", + Name: "Internal projects", + }, + Task: worklog.IDNameField{ + ID: "task-id", + Name: "TASK-0123", + }, + Summary: "Write worklog transfer CLI tool", + Notes: "It is a lot easier than expected", + Start: start, + BillableDuration: end.Sub(start), + UnbillableDuration: 0, + } +} + +func TestDefaultUploader_StartTracking(t *testing.T) { + entry := getTestEntry() + progressWriter := progress.NewWriter() + + uploader := client.DefaultUploader{} + tracker := uploader.StartTracking(entry, progressWriter) + + require.NotNil(t, tracker) +} + +func TestDefaultUploader_StartTracking_NoProgressWriter(t *testing.T) { + entry := getTestEntry() + + uploader := client.DefaultUploader{} + tracker := uploader.StartTracking(entry, nil) + + require.Nil(t, tracker) +} + +func TestDefaultUploader_StopTracking_Success(t *testing.T) { + entry := getTestEntry() + progressWriter := progress.NewWriter() + + uploader := client.DefaultUploader{} + + tracker := uploader.StartTracking(entry, progressWriter) + require.NotNil(t, tracker) + + uploader.StopTracking(tracker, nil) + require.True(t, tracker.IsDone()) + require.False(t, tracker.IsErrored()) +} + +func TestDefaultUploader_StopTracking_Failure(t *testing.T) { + entry := getTestEntry() + progressWriter := progress.NewWriter() + + uploader := client.DefaultUploader{} + + tracker := uploader.StartTracking(entry, progressWriter) + require.NotNil(t, tracker) + + uploader.StopTracking(tracker, errors.New("some error")) + require.True(t, tracker.IsDone()) + require.True(t, tracker.IsErrored()) +} + +func TestDefaultUploader_StopTracking_NoTracker(t *testing.T) { + entry := getTestEntry() + + uploader := client.DefaultUploader{} + + tracker := uploader.StartTracking(entry, nil) + require.Nil(t, tracker) + + uploader.StopTracking(tracker, nil) +} diff --git a/internal/pkg/utils/time.go b/internal/pkg/utils/time.go new file mode 100644 index 0000000..c2ccc55 --- /dev/null +++ b/internal/pkg/utils/time.go @@ -0,0 +1,36 @@ +package utils + +import "time" + +// DateFormat is the enumeration of available date formats, used by clients. +// Although the builtin time package contains several formatting options, some +// clients are using nonsense date time formats. +type DateFormat int + +const ( + // DateFormatISO8601 represents the ISO 8601 date format. + DateFormatISO8601 DateFormat = iota + // DateFormatRFC3339UTC is similar to RFC3339, but has no offset, in UTC. + DateFormatRFC3339UTC + // DateFormatRFC3339Compact is similar to RFC3339, but has no separation. + // This is not a standard date time format, it is used by Timewarrior. + DateFormatRFC3339Compact + // DateFormatRFC3339Local is similar to RFC3339, but lacks timezone info. + // This is not a standard date time format, it is used by Timewarrior. + DateFormatRFC3339Local +) + +// String returns the string representation of the format. +func (d DateFormat) String() string { + return []string{ + "2006-01-02", // DateFormatISO8601 + "2006-01-02T15:04:05Z", // DateFormatRFC3339UTC + "20060102T150405Z", // DateFormatRFC3339Compact + "2006-01-02T15:04:05", // DateFormatRFC3339Local + }[d] +} + +// Format returns the formatted version of the given time. +func (d DateFormat) Format(t time.Time) string { + return t.Format(d.String()) +} diff --git a/internal/pkg/worklog/entry.go b/internal/pkg/worklog/entry.go index 48f3869..a57530b 100644 --- a/internal/pkg/worklog/entry.go +++ b/internal/pkg/worklog/entry.go @@ -19,6 +19,22 @@ func (f IDNameField) IsComplete() bool { return f.ID != "" && f.Name != "" } +// Entries defines a collection of entries. +type Entries []Entry + +// GroupByTask groups the entries by task IDs and returns the grouped entries. +func (e *Entries) GroupByTask() map[string]Entries { + groups := make(map[string]Entries) + + for _, entry := range *e { + key := entry.Task.ID + entries := groups[key] + groups[key] = append(entries, entry) + } + + return groups +} + // Entry represents the worklog entry and contains all the necessary data. type Entry struct { Client IDNameField @@ -59,7 +75,7 @@ func (e *Entry) SplitDuration(parts int) (splitBillableDuration time.Duration, s // SplitByTagsAsTasks splits the entry into pieces treating tags as tasks. // Not matching tags won't be treated as a new entry should be created, // therefore that tag will be skipped and the returned entries will lack that. -func (e *Entry) SplitByTagsAsTasks(summary string, regex *regexp.Regexp, tags []IDNameField) []Entry { +func (e *Entry) SplitByTagsAsTasks(summary string, regex *regexp.Regexp, tags []IDNameField) Entries { var tasks []IDNameField for _, tag := range tags { if taskName := regex.FindString(tag.Name); taskName != "" { @@ -67,7 +83,7 @@ func (e *Entry) SplitByTagsAsTasks(summary string, regex *regexp.Regexp, tags [] } } - var entries []Entry + var entries Entries totalTasks := len(tasks) for _, task := range tasks { diff --git a/internal/pkg/worklog/entry_test.go b/internal/pkg/worklog/entry_test.go index 51621fa..f03474c 100644 --- a/internal/pkg/worklog/entry_test.go +++ b/internal/pkg/worklog/entry_test.go @@ -59,6 +59,18 @@ func TestIDNameFieldIsComplete(t *testing.T) { assert.True(t, field.IsComplete()) } +func TestEntries_GroupByTask(t *testing.T) { + entries := worklog.Entries{ + getCompleteTestEntry(), + getCompleteTestEntry(), + getCompleteTestEntry(), + } + + groups := entries.GroupByTask() + + assert.Equal(t, 1, len(groups)) +} + func TestEntryKey(t *testing.T) { entry := getCompleteTestEntry() assert.Equal(t, "Internal projects:TASK-0123:Write worklog transfer CLI tool:2021-10-02", entry.Key()) @@ -119,7 +131,7 @@ func TestEntry_SplitByTag(t *testing.T) { regex, err := regexp.Compile(`^TASK-\d+$`) require.Nil(t, err) - expectedEntries := []worklog.Entry{ + expectedEntries := worklog.Entries{ { Client: entry.Client, Project: entry.Project, diff --git a/internal/pkg/worklog/worklog.go b/internal/pkg/worklog/worklog.go index 28ec510..337cbde 100644 --- a/internal/pkg/worklog/worklog.go +++ b/internal/pkg/worklog/worklog.go @@ -9,23 +9,23 @@ import "regexp" // some APIs are not allowing filtering. Also, this way, we can filter results // using regex. type FilterOpts struct { - Client *regexp.Regexp - Project *regexp.Regexp + Client *regexp.Regexp + Project *regexp.Regexp } // Worklog is the collection of multiple Entries. type Worklog struct { - completeEntries []Entry - incompleteEntries []Entry + completeEntries Entries + incompleteEntries Entries } // CompleteEntries returns those entries which necessary fields were filled. -func (w *Worklog) CompleteEntries() []Entry { +func (w *Worklog) CompleteEntries() Entries { return w.completeEntries } // IncompleteEntries is the opposite of CompleteEntries. -func (w *Worklog) IncompleteEntries() []Entry { +func (w *Worklog) IncompleteEntries() Entries { return w.incompleteEntries } @@ -38,8 +38,8 @@ func isEntryMatching(entry Entry, opts *FilterOpts) bool { } // NewWorklog creates a worklog from the given set of entries and merges them. -func NewWorklog(entries []Entry, opts *FilterOpts) Worklog { - var filteredEntries []Entry +func NewWorklog(entries Entries, opts *FilterOpts) Worklog { + var filteredEntries Entries worklog := Worklog{} mergedEntries := map[string]Entry{} diff --git a/internal/pkg/worklog/worklog_test.go b/internal/pkg/worklog/worklog_test.go index e9546de..9b1a27e 100644 --- a/internal/pkg/worklog/worklog_test.go +++ b/internal/pkg/worklog/worklog_test.go @@ -10,13 +10,13 @@ import ( ) var newWorklogBenchResult worklog.Worklog -var completeEntriesBenchResult []worklog.Entry -var incompleteEntriesBenchResult []worklog.Entry +var completeEntriesBenchResult worklog.Entries +var incompleteEntriesBenchResult worklog.Entries func benchNewWorklog(b *testing.B, entryCount int) { b.StopTimer() - var entries []worklog.Entry + var entries worklog.Entries for i := 0; i != entryCount; i++ { entry := getCompleteTestEntry() @@ -36,7 +36,7 @@ func benchNewWorklog(b *testing.B, entryCount int) { func benchmarkCompleteEntries(b *testing.B, entryCount int) { b.StopTimer() - var entries []worklog.Entry + var entries worklog.Entries for i := 0; i != entryCount; i++ { entry := getCompleteTestEntry() @@ -58,7 +58,7 @@ func benchmarkCompleteEntries(b *testing.B, entryCount int) { func benchmarkIncompleteEntries(b *testing.B, entryCount int) { b.StopTimer() - var entries []worklog.Entry + var entries worklog.Entries for i := 0; i != entryCount; i++ { entry := getIncompleteTestEntry() @@ -116,7 +116,7 @@ func TestWorklogCompleteEntries(t *testing.T) { incompleteEntry := getCompleteTestEntry() incompleteEntry.Task = worklog.IDNameField{} - wl := worklog.NewWorklog([]worklog.Entry{ + wl := worklog.NewWorklog(worklog.Entries{ completeEntry, otherCompleteEntry, incompleteEntry, @@ -124,7 +124,7 @@ func TestWorklogCompleteEntries(t *testing.T) { entry := wl.CompleteEntries()[0] assert.Equal(t, "It is a lot easier than expected; Really", entry.Notes) - assert.Equal(t, []worklog.Entry{entry}, wl.CompleteEntries()) + assert.Equal(t, worklog.Entries{entry}, wl.CompleteEntries()) } func TestWorklogIncompleteEntries(t *testing.T) { @@ -137,7 +137,7 @@ func TestWorklogIncompleteEntries(t *testing.T) { otherIncompleteEntry.Task = worklog.IDNameField{} otherIncompleteEntry.Notes = "Well, not that easy" - wl := worklog.NewWorklog([]worklog.Entry{ + wl := worklog.NewWorklog(worklog.Entries{ completeEntry, incompleteEntry, otherIncompleteEntry, @@ -145,7 +145,7 @@ func TestWorklogIncompleteEntries(t *testing.T) { entry := wl.IncompleteEntries()[0] assert.Equal(t, "It is a lot easier than expected; Well, not that easy", entry.Notes) - assert.Equal(t, []worklog.Entry{entry}, wl.IncompleteEntries()) + assert.Equal(t, worklog.Entries{entry}, wl.IncompleteEntries()) } func TestWorklogFilterEntries(t *testing.T) { @@ -166,16 +166,16 @@ func TestWorklogFilterEntries(t *testing.T) { entry4.Project.Name = "website development" filterOpts := &worklog.FilterOpts{ - Client: regexp.MustCompile(`^ACME Inc\.?(orporation)?$`), - Project: regexp.MustCompile(`.*(website).*`), + Client: regexp.MustCompile(`^ACME Inc\.?(orporation)?$`), + Project: regexp.MustCompile(`.*(website).*`), } - wl := worklog.NewWorklog([]worklog.Entry{ + wl := worklog.NewWorklog(worklog.Entries{ entry1, entry2, entry3, entry4, }, filterOpts) - assert.Equal(t, []worklog.Entry{entry1, entry2}, wl.CompleteEntries()) -} \ No newline at end of file + assert.ElementsMatch(t, worklog.Entries{entry1, entry2}, wl.CompleteEntries()) +} diff --git a/www/docs/sources/toggl.md b/www/docs/sources/toggl.md index 54f471e..5ed22c9 100644 --- a/www/docs/sources/toggl.md +++ b/www/docs/sources/toggl.md @@ -36,7 +36,6 @@ The source provides the following extra configuration options. | Config option | Kind | Description | Example | | --------------- | ------ | ------------------------------------------------------------- | ----------------------------------------- | | toggl-api-key | string | API key gathered from Toggl Track[^1] | toggl-api-key = "" | -| toggl-url | string | Set the base URL for Toggl Track without a trailing slash[^2] | toggl-url = "https://api.track.toggl.com" | | toggl-workspace | int | Set the workspace ID | toggl-workspace = 123456789 | ## Limitations @@ -75,4 +74,3 @@ force-billed-duration = true ``` [^1]: The API key can be generated as described in their [documentation](https://support.toggl.com/en/articles/3116844-where-is-my-api-key-located). -[^2]: The URL defaults to `https://api.track.toggl.com` and Toggl Track cannot be installed privately, though they are changing domains nowadays, so if Toggl track changes domain again or start offering private hosting, it can be set easily.