Skip to content

Commit

Permalink
#260 - feat: allow filter by client alone on report (#261)
Browse files Browse the repository at this point in the history
* feat: allow reporting on multiple projects

* chore: prevent too many requests
  • Loading branch information
lucassabreu authored Mar 29, 2024
1 parent dd33e38 commit 5fd41ed
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 63 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions api/httpClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down
4 changes: 3 additions & 1 deletion internal/mocks/simple_config.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/project/edit/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
15 changes: 5 additions & 10 deletions pkg/cmd/project/edit/edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
85 changes: 62 additions & 23 deletions pkg/cmd/time-entry/report/util/report.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package util

import (
"errors"
"io"
"sort"
"time"
Expand All @@ -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 (
Expand All @@ -33,7 +33,7 @@ type ReportFlags struct {

Description string
Client string
Project string
Projects []string
TagIDs []string
}

Expand All @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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() {
Expand All @@ -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)
}
Expand Down
12 changes: 4 additions & 8 deletions pkg/cmd/time-entry/report/util/report_flag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading

0 comments on commit 5fd41ed

Please sign in to comment.