diff --git a/CHANGELOG.md b/CHANGELOG.md index e9059410..b6485779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- report subcommands now allowing passing multiple projects to search/filter +- report subcommands now will search all the time entries of a client with the flag `--client` without using + `--project` + ## [v0.48.2] - 2024-02-22 ### Fixed diff --git a/api/client.go b/api/client.go index 286c13f9..bed5b0ac 100644 --- a/api/client.go +++ b/api/client.go @@ -445,7 +445,18 @@ func (c *client) GetUsersHydratedTimeEntries(p GetUserTimeEntriesParam) ([]dto.T return timeEntries, err } - user, err := c.GetUser(GetUser{p.Workspace, p.UserID}) + var user dto.User + tries := 0 + for tries < 5 { + tries++ + user, err = c.GetUser(GetUser{p.Workspace, p.UserID}) + if err == nil || !errors.Is(err, ErrorTooManyRequests) { + break + } + + time.Sleep(time.Duration(5)) + } + if err != nil { return timeEntries, err } diff --git a/api/httpClient.go b/api/httpClient.go index 79ed028e..f57d58b7 100644 --- a/api/httpClient.go +++ b/api/httpClient.go @@ -22,6 +22,9 @@ var ErrorNotFound = dto.Error{Message: "Nothing was found", Code: 404} // ErrorForbidden Forbidden var ErrorForbidden = dto.Error{Message: "Forbidden", Code: 403} +// ErrorTooManyRequests Too Many Requests +var ErrorTooManyRequests = dto.Error{Message: "Too Many Requests", Code: 429} + type transport struct { apiKey string next http.RoundTripper @@ -112,6 +115,10 @@ func (c *client) Do( apiErr = ErrorForbidden } + if r.StatusCode == 429 && apiErr.Message == "" { + apiErr = ErrorTooManyRequests + } + if apiErr.Message == "" { apiErr.Message = "No response" } diff --git a/internal/mocks/simple_config.go b/internal/mocks/simple_config.go index 29d35827..656379f4 100644 --- a/internal/mocks/simple_config.go +++ b/internal/mocks/simple_config.go @@ -1,6 +1,8 @@ package mocks -import "github.com/lucassabreu/clockify-cli/pkg/cmdutil" +import ( + "github.com/lucassabreu/clockify-cli/pkg/cmdutil" +) // SimpleConfig is used to set configs for tests were changing the config or // accessing them with Get and All is not important diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go index 4da85109..c26bd86b 100644 --- a/pkg/cmd/project/edit/edit.go +++ b/pkg/cmd/project/edit/edit.go @@ -122,7 +122,7 @@ func NewCmdEdit( if f.Config().IsAllowNameForID() { if ids, err = search.GetProjectsByName( - c, w, ids); err != nil { + c, f.Config(), w, "", ids); err != nil { return err } diff --git a/pkg/cmd/project/edit/edit_test.go b/pkg/cmd/project/edit/edit_test.go index 76160612..cf91557a 100644 --- a/pkg/cmd/project/edit/edit_test.go +++ b/pkg/cmd/project/edit/edit_test.go @@ -107,8 +107,7 @@ func TestEditCmd(t *testing.T) { f := mocks.NewMockFactory(t) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - cf.On("IsAllowNameForID").Return(true) + cf := &mocks.SimpleConfig{AllowNameForID: true} f.On("Config").Return(cf) c := mocks.NewMockClient(t) @@ -134,8 +133,7 @@ func TestEditCmd(t *testing.T) { f := mocks.NewMockFactory(t) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - cf.On("IsAllowNameForID").Return(false) + cf := &mocks.SimpleConfig{} f.On("Config").Return(cf) c := mocks.NewMockClient(t) @@ -176,8 +174,7 @@ func TestEditCmd(t *testing.T) { f := mocks.NewMockFactory(t) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - cf.On("IsAllowNameForID").Return(true) + cf := &mocks.SimpleConfig{AllowNameForID: true} f.On("Config").Return(cf) c := mocks.NewMockClient(t) @@ -241,8 +238,7 @@ func TestEditCmd(t *testing.T) { f := mocks.NewMockFactory(t) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - cf.On("IsAllowNameForID").Return(true) + cf := &mocks.SimpleConfig{AllowNameForID: true} f.On("Config").Return(cf) c := mocks.NewMockClient(t) @@ -366,8 +362,7 @@ func TestEditCmdReport(t *testing.T) { f.On("GetWorkspaceID"). Return("w", nil) - cf := mocks.NewMockConfig(t) - cf.On("IsAllowNameForID").Return(false) + cf := &mocks.SimpleConfig{AllowNameForID: false} f.On("Config").Return(cf) c.On("UpdateProject", api.UpdateProjectParam{ diff --git a/pkg/cmd/time-entry/report/util/report.go b/pkg/cmd/time-entry/report/util/report.go index 7cb233e0..261b13ed 100644 --- a/pkg/cmd/time-entry/report/util/report.go +++ b/pkg/cmd/time-entry/report/util/report.go @@ -1,7 +1,6 @@ package util import ( - "errors" "io" "sort" "time" @@ -15,6 +14,7 @@ import ( "github.com/lucassabreu/clockify-cli/pkg/search" "github.com/lucassabreu/clockify-cli/pkg/timehlp" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" ) const ( @@ -33,7 +33,7 @@ type ReportFlags struct { Description string Client string - Project string + Projects []string TagIDs []string } @@ -43,12 +43,6 @@ func (rf ReportFlags) Check() error { return err } - if rf.Client != "" && rf.Project == "" { - return cmdutil.FlagErrorWrap(errors.New( - "flag 'client' can't be used without flag 'project'", - )) - } - return cmdutil.XorFlag(map[string]bool{ "billable": rf.Billable, "not-billable": rf.NotBillable, @@ -73,7 +67,7 @@ func AddReportFlags( "add empty lines for dates without time entries") cmd.Flags().StringVarP(&rf.Description, "description", "d", "", "will filter time entries that contains this on the description field") - cmd.Flags().StringVarP(&rf.Project, "project", "p", "", + cmd.Flags().StringSliceVarP(&rf.Projects, "project", "p", []string{}, "Will filter time entries using this project") _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", cmdcomplutil.NewProjectAutoComplete(f)) @@ -113,11 +107,35 @@ func ReportWithRange( } cnf := f.Config() - if rf.Project != "" && f.Config().IsAllowNameForID() { - if rf.Project, err = search.GetProjectByName( - c, cnf, workspace, rf.Project, rf.Client); err != nil { + if len(rf.Projects) != 0 { + if f.Config().IsAllowNameForID() { + if rf.Projects, err = search.GetProjectsByName( + c, cnf, workspace, rf.Client, rf.Projects); err != nil { + return err + } + } + } else if rf.Client != "" { + if f.Config().IsAllowNameForID() { + if rf.Client, err = search.GetClientByName( + c, workspace, rf.Client); err != nil { + return err + } + } + + ps, err := c.GetProjects(api.GetProjectsParam{ + Workspace: workspace, + Clients: []string{rf.Client}, + Hydrate: false, + PaginationParam: api.AllPages(), + }) + if err != nil { return err } + + rf.Projects = make([]string, len(ps)) + for i := range ps { + rf.Projects[i] = ps[i].ID + } } if len(rf.TagIDs) > 0 && f.Config().IsAllowNameForID() { @@ -127,23 +145,44 @@ func ReportWithRange( } } + if len(rf.Projects) == 0 { + rf.Projects = []string{""} + } + start = timehlp.TruncateDate(start) end = timehlp.TruncateDate(end).Add(time.Hour * 24) - log, err := c.LogRange(api.LogRangeParam{ - Workspace: workspace, - UserID: userId, - FirstDate: start, - LastDate: end, - Description: rf.Description, - ProjectID: rf.Project, - TagIDs: rf.TagIDs, - PaginationParam: api.AllPages(), - }) - if err != nil { + wg := errgroup.Group{} + logs := make([][]dto.TimeEntry, len(rf.Projects)) + + for i := range rf.Projects { + i := i + wg.Go(func() error { + var err error + logs[i], err = c.LogRange(api.LogRangeParam{ + Workspace: workspace, + UserID: userId, + FirstDate: start, + LastDate: end, + Description: rf.Description, + ProjectID: rf.Projects[i], + TagIDs: rf.TagIDs, + PaginationParam: api.AllPages(), + }) + + return err + }) + } + + if err = wg.Wait(); err != nil { return err } + log := make([]dto.TimeEntry, 0) + for i := range logs { + log = append(log, logs[i]...) + } + if rf.Billable || rf.NotBillable { log = filterBilling(log, rf.Billable) } diff --git a/pkg/cmd/time-entry/report/util/report_flag_test.go b/pkg/cmd/time-entry/report/util/report_flag_test.go index 01473210..7f59ecae 100644 --- a/pkg/cmd/time-entry/report/util/report_flag_test.go +++ b/pkg/cmd/time-entry/report/util/report_flag_test.go @@ -32,21 +32,17 @@ func TestReportBillableFlagsChecks(t *testing.T) { func TestReportProjectFlagsChecks(t *testing.T) { rf := util.NewReportFlags() rf.Client = "me" - rf.Project = "" + rf.Projects = []string{} - err := rf.Check() - if assert.Error(t, err) { - assert.Equal(t, - "flag 'client' can't be used without flag 'project'", err.Error()) - } + assert.NoError(t, rf.Check()) rf.Client = "" - rf.Project = "mine" + rf.Projects = []string{"mine"} assert.NoError(t, rf.Check()) rf.Client = "me" - rf.Project = "mine" + rf.Projects = []string{"mine"} assert.NoError(t, rf.Check()) } diff --git a/pkg/cmd/time-entry/report/util/reportwithrange_test.go b/pkg/cmd/time-entry/report/util/reportwithrange_test.go index 0d5680fe..2c5135c9 100644 --- a/pkg/cmd/time-entry/report/util/reportwithrange_test.go +++ b/pkg/cmd/time-entry/report/util/reportwithrange_test.go @@ -102,7 +102,7 @@ func TestReportWithRange(t *testing.T) { }, flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() - rf.Project = "p" + rf.Projects = []string{"p"} return rf }, err: "http error", @@ -131,7 +131,7 @@ func TestReportWithRange(t *testing.T) { }, flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() - rf.Project = "wrong" + rf.Projects = []string{"wrong"} return rf }, err: "No project.*wrong' was found", @@ -160,7 +160,7 @@ func TestReportWithRange(t *testing.T) { flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() rf.Client = "right" - rf.Project = "wrong" + rf.Projects = []string{"wrong"} return rf }, err: "No client.*right' was found", @@ -197,7 +197,7 @@ func TestReportWithRange(t *testing.T) { flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() rf.Client = "right" - rf.Project = "wrong" + rf.Projects = []string{"wrong"} return rf }, err: "No project.*wrong' was found for client 'right'", @@ -250,7 +250,7 @@ func TestReportWithRange(t *testing.T) { }, flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() - rf.Project = "right" + rf.Projects = []string{"right"} rf.Client = "right" return rf }, @@ -289,7 +289,7 @@ func TestReportWithRange(t *testing.T) { }, flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() - rf.Project = "p" + rf.Projects = []string{"p"} rf.Description = "desc" rf.Quiet = true return rf @@ -306,8 +306,7 @@ func TestReportWithRange(t *testing.T) { f.On("GetUserID").Return("u", nil) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - f.On("Config").Return(cf) + f.On("Config").Return(&mocks.SimpleConfig{}) c := mocks.NewMockClient(t) f.On("Client").Return(c, nil) @@ -349,8 +348,7 @@ func TestReportWithRange(t *testing.T) { f.On("GetUserID").Return("u", nil) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - f.On("Config").Return(cf) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) c := mocks.NewMockClient(t) f.On("Client").Return(c, nil) @@ -387,8 +385,7 @@ func TestReportWithRange(t *testing.T) { f.On("GetUserID").Return("u", nil) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - f.On("Config").Return(cf) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) c := mocks.NewMockClient(t) f.On("Client").Return(c, nil) @@ -424,9 +421,9 @@ func TestReportWithRange(t *testing.T) { f.On("GetUserID").Return("u", nil) f.On("GetWorkspaceID").Return("w", nil) - cf := mocks.NewMockConfig(t) - f.On("Config").Return(cf) - cf.On("IsAllowNameForID").Return(true) + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + }) c := mocks.NewMockClient(t) f.On("Client").Return(c, nil) @@ -465,6 +462,163 @@ func TestReportWithRange(t *testing.T) { te-4 `), }, + { + name: "multiple projects", + factory: func(t *testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + f.EXPECT().Config().Return( + &mocks.SimpleConfig{AllowNameForID: true}) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }).Return([]dto.Project{ + {ID: "p1", Name: "p1"}, + {ID: "p2", Name: "p2"}, + {ID: "p3", Name: "p3"}, + }, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p1", + FirstDate: first, + LastDate: last, + PaginationParam: api.AllPages(), + }).Return([]dto.TimeEntry{ + {ID: "te-1", + TimeInterval: dto.TimeInterval{ + Start: first, + }, + }, + {ID: "te-3", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(2)), + }, + }, + }, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p2", + FirstDate: first, + LastDate: last, + PaginationParam: api.AllPages(), + }).Return([]dto.TimeEntry{ + {ID: "te-2", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(1)), + }, + }, + {ID: "te-4", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(3)), + }, + }, + }, nil) + + return f + }, + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Quiet = true + rf.Projects = []string{"p1", "p2"} + return rf + }, + expected: heredoc.Doc(` + te-1 + te-2 + te-3 + te-4 + `), + }, + { + name: "projects form a client", + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Quiet = true + rf.Client = "me" + return rf + }, + factory: func(t *testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + f.EXPECT().Config().Return( + &mocks.SimpleConfig{AllowNameForID: true}) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.EXPECT().GetClients(api.GetClientsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }). + Return([]dto.Client{ + {ID: "c1", Name: "me"}, + {ID: "c2", Name: "you"}, + }, nil) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: "w", + Clients: []string{"c1"}, + PaginationParam: api.AllPages(), + }).Return([]dto.Project{ + {ID: "p1", Name: "p1", ClientID: "c1", ClientName: "me"}, + {ID: "p3", Name: "p3", ClientID: "c1", ClientName: "me"}, + }, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p1", + FirstDate: first, + LastDate: last, + PaginationParam: api.AllPages(), + }).Return([]dto.TimeEntry{ + {ID: "te-1", + TimeInterval: dto.TimeInterval{ + Start: first, + }, + }, + {ID: "te-3", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(2)), + }, + }, + }, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p3", + FirstDate: first, + LastDate: last, + PaginationParam: api.AllPages(), + }).Return([]dto.TimeEntry{ + {ID: "te-2", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(1)), + }, + }, + }, nil) + + return f + }, + expected: heredoc.Doc(` + te-1 + te-2 + te-3 + `), + }, } for _, tt := range tts { diff --git a/pkg/search/project.go b/pkg/search/project.go index a86f1a0b..3284d0e1 100644 --- a/pkg/search/project.go +++ b/pkg/search/project.go @@ -116,14 +116,16 @@ func filterClientProjects( // name or id that matches the value func GetProjectsByName( c api.Client, + cnf cmdutil.Config, workspace string, + client string, projects []string, ) ([]string, error) { if len(projects) == 0 { return projects, nil } - ts, err := c.GetProjects(api.GetProjectsParam{ + ps, err := c.GetProjects(api.GetProjectsParam{ Workspace: workspace, PaginationParam: api.AllPages(), }) @@ -131,9 +133,23 @@ func GetProjectsByName( return projects, err } - ns := make([]named, len(ts)) + if ps, err = filterClientProjects(ps, client); err != nil { + return projects, err + } + + toNamed := func(p dto.Project) named { return p } + if cnf.IsSearchProjectWithClientsName() { + toNamed = func(p dto.Project) named { + return namedStruct{ + ID: p.ID, + Name: p.Name + " " + p.ClientName, + } + } + } + + ns := make([]named, len(ps)) for i := 0; i < len(ns); i++ { - ns[i] = ts[i] + ns[i] = toNamed(ps[i]) } var g errgroup.Group @@ -153,5 +169,17 @@ func GetProjectsByName( }) } - return projects, g.Wait() + err = g.Wait() + var eNotFound ErrNotFound + if client != "" && errors.As(err, &eNotFound) { + err = ErrNotFound{ + EntityName: eNotFound.EntityName, + Reference: eNotFound.Reference, + Filters: map[string]string{ + "client": client, + }, + } + } + + return projects, err }