diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aca46d5..ad7437dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- CLAUDE.md project guidance documentation with codebase overview and development patterns +- `edit` command now validates that all time entries share the same project when only changing the task without `--project` flag + +### Fixed + +- `edit` command now correctly applies `--billable` flag when editing multiple time entries in non-interactive mode + +### Changed + +- `edit` and `edit-multiple` commands merged into a single `edit` command that accepts one or more time entry IDs + ## [v0.63.2] - 2026-05-21 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..68cfd843 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Clockify CLI is a command-line tool for managing time entries and projects on Clockify from the terminal. It's written in Go and built on the Cobra framework for CLI command handling. + +**Key Technologies:** +- Go 1.24+ (module-based) +- Cobra for CLI commands +- Viper for configuration management +- Survey for interactive prompts +- GoReleaser for releases + +## Building and Development + +### Installation and Preparation + +```bash +make deps-install # Install Go dependencies +``` + +### Building + +```bash +make go-install # Build and install dev version to $GOBIN +make dist # Build all versions (darwin, linux, windows) → dist/ +go run cmd/clockify-cli/main.go # Run directly from source +``` + +### Testing + +```bash +make test # Run all tests with gotestsum +make test-watch # Run tests with file watcher (fails fast) +make test-coverage # Run tests with coverage report +``` + +### Code Generation + +```bash +make go-generate # Regenerate mocks using mockery +``` + +## Project Structure + +- **`cmd/`** - Main executable packages + - `cmd/clockify-cli/main.go` - Entry point (version info, error handling, Viper config binding) + - `cmd/release/` - Release automation + - `cmd/gendocs/` - Documentation generation + +- **`api/`** - Clockify API client implementation + - `api/client.go` - Main HTTP client for API calls + - `api/dto/` - Data transfer objects and request builders + +- **`pkg/cmd/`** - CLI command implementation (follows directory-based routing) + - Subdirectories for command groups: `client/`, `config/`, `project/`, `tag/`, `task/`, `time-entry/`, `user/`, `version/`, `workspace/` + - Pattern: Command at `pkg/cmd///.go` + - Each command has a `NewCmd()` factory function returning `*cobra.Command` + +- **`pkg/output/`** - Output formatters + - Pattern: `pkg/output//.go` + - Formats: table (default), json, quiet (ID only), template (Go template), csv + +- **`pkg/`** - Shared packages + - `cmdutil/` - Command utilities, configuration constants (CONF_TOKEN, CONF_WORKSPACE, etc.) + - `cmdcomplutil/` - Completion utilities + - `outpututil/` - Shared output logic + - `timeentryhlp/`, `timehlp/` - Time entry and time helpers + - `ui/` - UI components (prompts, selections) + +- **`internal/`** - Project-specific test utilities + - `testhlp/` - Test helpers + - `consoletest/` - Console testing utilities + - `mocks/` - Generated mocks + +## Command Architecture + +Commands follow the Cobra pattern: + +1. **Factory Functions:** Each command is created via `NewCmd(factory cmdutil.Factory) *cobra.Command` +2. **Command Groups:** Parent commands register subcommands (e.g., `pkg/cmd/client/client.go` registers its subcommands) +3. **Configuration:** Commands access config through `factory.Config()` which reads from: + - Environment variables (prefix: `CLOCKIFY_`) + - Config file: `~/.config/clockify-cli/config.yaml` or `~/.clockify-cli` + - CLI flags (with viper binding) + +**Adding a New Command:** +1. Create `pkg/cmd///.go` +2. Implement `NewCmd(factory cmdutil.Factory) *cobra.Command` +3. Register in parent command's `NewCmd()` function +4. If first command for entity, create output formatters at `pkg/output//` for: table, json, quiet, template, csv + +## Key Patterns and Conventions + +- **Configuration keys:** Defined in `pkg/cmdutil/` (e.g., `CONF_TOKEN`, `CONF_WORKSPACE`, `CONF_USER_ID`) +- **CLI Flags:** Bound to Viper config via `bindViper()` in main.go, allowing env var and config file overrides +- **Environment Variables:** Use `CLOCKIFY_` prefix (e.g., `CLOCKIFY_TOKEN`, `CLOCKIFY_WORKSPACE`) +- **Output:** Commands use output formatters from `pkg/output/` to support multiple formats +- **Mocks:** Generated by `mockery` during `make go-generate` — run this before testing if interfaces change + +## Testing + +- **Framework:** testify assertions + gotestsum runner +- **Test Discovery:** `**/*_test.go` pattern +- **Mocks:** `internal/mocks/` — regenerate with `make go-generate` when interfaces change +- **Flags:** Use `-failfast` in `make test-watch` (configured in Makefile) + +## Changelog + +Every code change **must** be documented in `CHANGELOG.md` under the `## [Unreleased]` section. Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format: + +- **Added** — for new features or functionality +- **Changed** — for changes to existing functionality +- **Deprecated** — for soon-to-be removed features +- **Removed** — for removed features +- **Fixed** — for bug fixes +- **Security** — for security fixes + +Each change is one bullet point written from the user's perspective (what changed, not implementation details). Do not commit changes without updating the changelog first. + +## Git Commits + +Always ask the user for permission before creating any commit. Do not run `git commit` without explicit user approval — confirm what will be committed and ask for approval before proceeding. diff --git a/pkg/cmd/time-entry/edit-multipple/edit-multiple.go b/pkg/cmd/time-entry/edit-multipple/edit-multiple.go deleted file mode 100644 index bd7f9803..00000000 --- a/pkg/cmd/time-entry/edit-multipple/edit-multiple.go +++ /dev/null @@ -1,218 +0,0 @@ -package editmultiple - -import ( - "github.com/MakeNowJust/heredoc" - "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util" - "github.com/lucassabreu/clockify-cli/pkg/cmdutil" - output "github.com/lucassabreu/clockify-cli/pkg/output/time-entry" - "github.com/lucassabreu/clockify-cli/pkg/timeentryhlp" - - "github.com/lucassabreu/clockify-cli/api" - "github.com/lucassabreu/clockify-cli/api/dto" - "github.com/spf13/cobra" -) - -// NewCmdEditMultiple represents the editMultiple command -func NewCmdEditMultiple(f cmdutil.Factory) *cobra.Command { - of := util.OutputFlags{TimeFormat: output.TimeFormatSimple} - cmd := &cobra.Command{ - Use: "edit-multiple { | " + - timeentryhlp.AliasCurrent + " | " + timeentryhlp.AliasLast + - " }...", - Aliases: []string{ - "update-multiple", "multi-edit", - "multi-update", "mult-edit", "mult-update", - }, - Args: cobra.MatchAll( - cmdutil.RequiredNamedArgs("time entry id"), - cobra.MinimumNArgs(2), - ), - ValidArgs: []string{timeentryhlp.AliasLast, timeentryhlp.AliasCurrent}, - Short: `Edit multiple time entries at once`, - Long: heredoc.Docf(` - Edit multiple time entries at once. - - This command does not allow to edit when the time entries start or ended, because different time entries will have different start and end times. - - Except on interactive mode where the values informed, even if not changed will be applied to all entries (except for Start and End time). - If you wanna edit only some properties, than use the flags without interactive mode, only the input sent thought the flags will be changed. - - %s - %s - %s - %s - `, - util.HelpTimeEntriesAliasForEdit, - util.HelpInteractiveByDefault, - util.HelpNamesForIds, - util.HelpMoreInfoAboutPrinting, - ), - Example: heredoc.Docf(` - # just to help show the data - $ export F="{{.ID}} :: {{ .Description }} - When: {{ fdt .TimeInterval.Start }} util {{ ft (.TimeInterval.End | now) }} - Task: {{ .Task.Name }} ({{ .Project.Name}}) - Tags: {{ .Tags }} - " - - $ %[1]s report --format "$F" - 62af667c4ebb4f143c9482bb :: Edit multiple entries - When: 2022-06-19 18:10:01 util 18:10:15 - Task: Edit Command (Clockify Cli) - Tags: [Development (62ae28b72518aa18da2acb49)] - - 62af668b49445270d7c092e4 :: Adding examples - When: 2022-06-19 18:10:15 util 18:29:32 - Task: Edit Command (Clockify Cli) - Tags: [Development (62ae28b72518aa18da2acb49)] - - 62af6b0f4ebb4f143c94880e :: More examples - When: 2022-06-19 18:29:32 util 18:38:12 - Task: Edit Command (Clockify Cli) - Tags: [Development (62ae28b72518aa18da2acb49)] - - # change all to use other task - $ %[1]s edit-multiple -i=0 -f "$F" current last ^2 --task multiple - 62af6b0f4ebb4f143c94880e :: More examples - When: 2022-06-19 18:29:32 util 18:43:04 - Task: Edit Multiple Command (Clockify Cli) - Tags: [Development (62ae28b72518aa18da2acb49)] - 62af668b49445270d7c092e4 :: Adding examples - When: 2022-06-19 18:10:15 util 18:29:32 - Task: Edit Multiple Command (Clockify Cli) - Tags: [Development (62ae28b72518aa18da2acb49)] - 62af668b49445270d7c092e4 :: Adding examples - When: 2022-06-19 18:10:15 util 18:29:32 - Task: Edit Multiple Command (Clockify Cli) - Tags: [Development (62ae28b72518aa18da2acb49)] - `, "clockify-cli"), - RunE: func(cmd *cobra.Command, args []string) error { - var err error - var w, u string - - if w, err = f.GetWorkspaceID(); err != nil { - return err - } - - if u, err = f.GetUserID(); err != nil { - return err - } - - c, err := f.Client() - if err != nil { - return err - } - - teis := make([]util.TimeEntryDTO, len(args)) - for i := range args { - t, err := timeentryhlp.GetTimeEntry(c, w, u, args[i]) - if err != nil { - return err - } - teis[i] = util.TimeEntryImplToDTO(t) - } - - tei := teis[0] - editFn := func(tei util.TimeEntryDTO) (util.TimeEntryDTO, error) { - t, err := c.UpdateTimeEntry(api.UpdateTimeEntryParam{ - Workspace: tei.Workspace, - TimeEntryID: tei.ID, - Description: tei.Description, - Start: tei.Start, - End: tei.End, - Billable: *tei.Billable, - ProjectID: tei.ProjectID, - TaskID: tei.TaskID, - TagIDs: tei.TagIDs, - }) - - return util.TimeEntryImplToDTO(t), err - } - - fn := func(input util.TimeEntryDTO) (util.TimeEntryDTO, error) { - var err error - for i, tei := range teis { - input.Start = tei.Start - input.End = tei.End - input.ID = tei.ID - - if tei, err = editFn(input); err != nil { - return input, err - } - - teis[i] = tei - } - - return input, err - } - - if !f.Config().IsInteractive() { - fn = func(input util.TimeEntryDTO) (util.TimeEntryDTO, error) { - c := cmd.Flags().Changed - for i, tei := range teis { - if c("project") { - tei.ProjectID = input.ProjectID - } - - if c("description") { - tei.Description = input.Description - } - - if c("task") { - tei.TaskID = input.TaskID - } - - if c("tag") || c("tags") { - tei.TagIDs = input.TagIDs - } - - if c("not-billable") { - tei.Billable = input.Billable - } - - teis[i] = tei - if _, err = editFn(tei); err != nil { - return tei, err - } - } - return input, nil - } - } - - dc := util.NewDescriptionCompleter(f) - - if _, err = util.Do( - tei, - util.FillTimeEntryWithFlags(cmd.Flags()), - util.GetAllowNameForIDsFn(f.Config(), c), - util.GetPropsInteractiveFn(dc, f), - util.GetValidateTimeEntryFn(f), - fn, - ); err != nil { - return err - } - - tes := make([]dto.TimeEntry, len(teis)) - var t *dto.TimeEntry - for i, tei := range teis { - t, err = c.GetHydratedTimeEntry(api.GetTimeEntryParam{ - TimeEntryID: tei.ID, - Workspace: tei.Workspace, - }) - - if err != nil { - return err - } - tes[i] = *t - } - - return util.PrintTimeEntries(tes, - cmd.OutOrStdout(), f.Config(), of) - }, - } - - util.AddTimeEntryFlags(cmd, f, &of) - util.AddPrintMultipleTimeEntriesFlags(cmd) - - return cmd -} diff --git a/pkg/cmd/time-entry/edit/edit.go b/pkg/cmd/time-entry/edit/edit.go index 4436bbc2..8bebc048 100644 --- a/pkg/cmd/time-entry/edit/edit.go +++ b/pkg/cmd/time-entry/edit/edit.go @@ -1,6 +1,7 @@ package edit import ( + "errors" "io" "github.com/MakeNowJust/heredoc" @@ -24,18 +25,30 @@ func NewCmdEdit( timeentryhlp.AliasCurrent, timeentryhlp.AliasLast} cmd := &cobra.Command{ Use: "edit { | " + va.IntoUseOptions() + - " | ^n }", - Aliases: []string{"update"}, + " | ^n }...", + Aliases: []string{ + "update", + "update-multiple", "multi-edit", + "multi-update", "mult-edit", "mult-update", + }, Args: cobra.MatchAll( cmdutil.RequiredNamedArgs("time entry id"), - cobra.ExactArgs(1), + cobra.MinimumNArgs(1), ), ValidArgs: va.IntoValidArgs(), - Short: `Edit a time entry`, + Short: `Edit one or more time entries`, Long: heredoc.Docf(` - Edit a time entry. + Edit one or more time entries. + + When editing a single time entry, you can use --when and --when-to-close to change when it started or ended. Only the inputs sent thought flags will be changed, any other properties will remain the same. + When editing multiple time entries, you can change all properties except for when they start or end, + as different time entries will have different start and end times. + + Except on interactive mode where the values informed, even if not changed will be applied to all entries + (except for Start and End time). + %s %s %s @@ -90,6 +103,31 @@ func NewCmdEdit( Tags: * Pair Programming (%[2]s621948708cb9606d934ebba7%[2]s) + + # just to help show the data + $ export F="{{.ID}} :: {{ .Description }} + When: {{ fdt .TimeInterval.Start }} util {{ ft (.TimeInterval.End | now) }} + Task: {{ .Task.Name }} ({{ .Project.Name}}) + Tags: {{ .Tags }} + " + + # change all to use other task + $ %[1]s edit-multiple -i=0 -f "$F" current last ^2 --task multiple + 62af6b0f4ebb4f143c94880e :: More examples + When: 2022-06-19 18:29:32 util 18:43:04 + Task: Edit Multiple Command (Clockify Cli) + Tags: [Development (62ae28b72518aa18da2acb49)] + 62af668b49445270d7c092e4 :: Adding examples + When: 2022-06-19 18:10:15 util 18:29:32 + Task: Edit Multiple Command (Clockify Cli) + Tags: [Development (62ae28b72518aa18da2acb49)] + 62af668b49445270d7c092e4 :: Adding examples + When: 2022-06-19 18:10:15 util 18:29:32 + Task: Edit Multiple Command (Clockify Cli) + Tags: [Development (62ae28b72518aa18da2acb49)] + + Tags: + * Pair Programming (%[2]s621948708cb9606d934ebba7%[2]s) `, "clockify-cli", "`"), RunE: func(cmd *cobra.Command, args []string) error { if err := of.Check(); err != nil { @@ -111,49 +149,166 @@ func NewCmdEdit( return err } - tei, err := timeentryhlp.GetTimeEntry( - c, - w, - userID, - args[0], - ) - if err != nil { - return err + if len(args) > 1 { + if cmd.Flags().Changed("when") || cmd.Flags().Changed("when-to-close") { + return errors.New("--when and --when-to-close can only be used when editing a single time entry") + } + } + + teis := make([]util.TimeEntryDTO, len(args)) + for i := range args { + t, err := timeentryhlp.GetTimeEntry(c, w, userID, args[i]) + if err != nil { + return err + } + teis[i] = util.TimeEntryImplToDTO(t) } - te := util.TimeEntryImplToDTO(tei) dc := util.NewDescriptionCompleter(f) - if te, err = util.Do( - te, + if len(args) == 1 { + te := teis[0] + if te, err = util.Do( + te, + util.FillTimeEntryWithFlags(cmd.Flags()), + util.GetAllowNameForIDsFn(f.Config(), c), + util.GetPropsInteractiveFn(dc, f), + util.GetDatesInteractiveFn(f), + util.GetValidateTimeEntryFn(f), + ); err != nil { + return err + } + + tei, err := c.UpdateTimeEntry(api.UpdateTimeEntryParam{ + Workspace: te.Workspace, + TimeEntryID: te.ID, + Description: te.Description, + Start: te.Start, + End: te.End, + Billable: *te.Billable, + ProjectID: te.ProjectID, + TaskID: te.TaskID, + TagIDs: te.TagIDs, + }) + if err != nil { + return err + } + + return report(tei, cmd.OutOrStdout(), of) + } + + tei := teis[0] + editFn := func(tei util.TimeEntryDTO) (util.TimeEntryDTO, error) { + t, err := c.UpdateTimeEntry(api.UpdateTimeEntryParam{ + Workspace: tei.Workspace, + TimeEntryID: tei.ID, + Description: tei.Description, + Start: tei.Start, + End: tei.End, + Billable: *tei.Billable, + ProjectID: tei.ProjectID, + TaskID: tei.TaskID, + TagIDs: tei.TagIDs, + }) + + return util.TimeEntryImplToDTO(t), err + } + + fn := func(input util.TimeEntryDTO) (util.TimeEntryDTO, error) { + var err error + for i, tei := range teis { + input.Start = tei.Start + input.End = tei.End + input.ID = tei.ID + + if tei, err = editFn(input); err != nil { + return input, err + } + + teis[i] = tei + } + + return input, err + } + + if !f.Config().IsInteractive() { + fn = func(input util.TimeEntryDTO) (util.TimeEntryDTO, error) { + changed := cmd.Flags().Changed + + if changed("task") && !changed("project") { + projectID := teis[0].ProjectID + for _, te := range teis[1:] { + if te.ProjectID != projectID { + return input, errors.New("you are changing the task of the time entries, but not the project and some of them are not in the same project, please also set --project") + } + } + } + + for i, tei := range teis { + if changed("project") { + if tei.ProjectID != input.ProjectID { + tei.TaskID = "" + } + tei.ProjectID = input.ProjectID + } + + if changed("description") { + tei.Description = input.Description + } + + if changed("task") { + tei.TaskID = input.TaskID + } + + if changed("tag") || changed("tags") { + tei.TagIDs = input.TagIDs + } + + if changed("billable") || changed("not-billable") { + tei.Billable = input.Billable + } + + teis[i] = tei + if _, err = editFn(tei); err != nil { + return tei, err + } + } + return input, nil + } + } + + if _, err = util.Do( + tei, util.FillTimeEntryWithFlags(cmd.Flags()), util.GetAllowNameForIDsFn(f.Config(), c), util.GetPropsInteractiveFn(dc, f), - util.GetDatesInteractiveFn(f), util.GetValidateTimeEntryFn(f), + fn, ); err != nil { return err } - if tei, err = c.UpdateTimeEntry(api.UpdateTimeEntryParam{ - Workspace: te.Workspace, - TimeEntryID: te.ID, - Description: te.Description, - Start: te.Start, - End: te.End, - Billable: *te.Billable, - ProjectID: te.ProjectID, - TaskID: te.TaskID, - TagIDs: te.TagIDs, - }); err != nil { - return err + tes := make([]dto.TimeEntry, len(teis)) + var t *dto.TimeEntry + for i, tei := range teis { + t, err = c.GetHydratedTimeEntry(api.GetTimeEntryParam{ + TimeEntryID: tei.ID, + Workspace: tei.Workspace, + }) + + if err != nil { + return err + } + tes[i] = *t } - return report(tei, cmd.OutOrStdout(), of) + return util.PrintTimeEntries(tes, + cmd.OutOrStdout(), f.Config(), of) }, } util.AddTimeEntryFlags(cmd, f, &of) + util.AddPrintMultipleTimeEntriesFlags(cmd) cmd.Flags().StringP("when", "s", "", "when the entry should be started") diff --git a/pkg/cmd/time-entry/edit/edit_test.go b/pkg/cmd/time-entry/edit/edit_test.go index 9c8fe7f7..528ddd36 100644 --- a/pkg/cmd/time-entry/edit/edit_test.go +++ b/pkg/cmd/time-entry/edit/edit_test.go @@ -2,6 +2,7 @@ package edit_test import ( "bytes" + "errors" "io" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) func TestNewCmdEditWhenChangingProjectOrTask(t *testing.T) { @@ -160,3 +162,556 @@ func TestNewCmdEditWhenChangingProjectOrTask(t *testing.T) { }) } } + +func TestNewCmdEditSingleEntryErrors(t *testing.T) { + w := dto.Workspace{ID: "w"} + te := dto.TimeEntryImpl{ + WorkspaceID: w.ID, + ID: "timeentryid", + Description: "Something", + ProjectID: "oldproj", + TaskID: "oldtask", + Billable: false, + TimeInterval: dto.TimeInterval{ + Start: time.Now(), + }, + } + + teBillable := te + teBillable.Billable = true + + tts := []struct { + name string + args []string + te dto.TimeEntryImpl + project *dto.Project + updateParam *api.UpdateTimeEntryParam + err string + }{ + { + name: "should fail when project is not found", + args: []string{"-p", "nonexistent", "current", "-q"}, + te: te, + err: "project not found", + }, + { + name: "should set billable to true", + args: []string{"-b", "current", "-q"}, + te: te, + project: &dto.Project{ID: te.ProjectID, Name: "oldproj"}, + updateParam: &api.UpdateTimeEntryParam{ + Workspace: te.WorkspaceID, + TimeEntryID: te.ID, + Start: te.TimeInterval.Start, + End: te.TimeInterval.End, + Billable: true, + Description: te.Description, + ProjectID: te.ProjectID, + TaskID: te.TaskID, + TagIDs: te.TagIDs, + }, + }, + { + name: "should set billable to false", + args: []string{"--not-billable", "current", "-q"}, + te: teBillable, + project: &dto.Project{ID: teBillable.ProjectID, Name: "oldproj"}, + updateParam: &api.UpdateTimeEntryParam{ + Workspace: teBillable.WorkspaceID, + TimeEntryID: teBillable.ID, + Start: teBillable.TimeInterval.Start, + End: teBillable.TimeInterval.End, + Billable: false, + Description: teBillable.Description, + ProjectID: teBillable.ProjectID, + TaskID: teBillable.TaskID, + TagIDs: teBillable.TagIDs, + }, + }, + { + name: "should fail when update fails", + args: []string{"-d", "test", "current", "-q"}, + te: te, + project: &dto.Project{ID: te.ProjectID, Name: "oldproj"}, + err: "API error", + }, + } + + for i := range tts { + tt := &tts[i] + t.Run(tt.name, func(t *testing.T) { + f := mocks.NewMockFactory(t) + + f.EXPECT().GetUserID().Return("u", nil) + f.EXPECT().GetWorkspace().Return(w, nil) + f.EXPECT().GetWorkspaceID().Return(w.ID, nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: false, + }) + + c := mocks.NewMockClient(t) + f.EXPECT().Client().Return(c, nil) + + c.EXPECT().GetTimeEntryInProgress(api.GetTimeEntryInProgressParam{ + Workspace: "w", + UserID: "u", + }). + Return(&tt.te, nil) + + if tt.project != nil { + c.EXPECT().GetProject(api.GetProjectParam{ + Workspace: w.ID, + ProjectID: tt.project.ID, + }). + Return(tt.project, nil) + } + + if tt.err == "project not found" { + c.EXPECT().GetProject(api.GetProjectParam{ + Workspace: w.ID, + ProjectID: "nonexistent", + }). + Return(nil, errors.New("project not found")) + } + + if tt.updateParam != nil { + c.EXPECT().UpdateTimeEntry(*tt.updateParam). + Return(dto.TimeEntryImpl{}, nil) + } + + if tt.err == "API error" { + c.EXPECT().UpdateTimeEntry(mock.Anything). + Return(dto.TimeEntryImpl{}, errors.New("API error")) + } + + called := false + cmd := edit.NewCmdEdit(f, func( + _ dto.TimeEntryImpl, _ io.Writer, _ util.OutputFlags) error { + called = true + return nil + }) + + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + out := bytes.NewBufferString("") + cmd.SetOut(out) + cmd.SetErr(out) + + cmd.SetArgs(tt.args) + _, err := cmd.ExecuteC() + + if tt.err != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.err) + return + } + + if assert.NoError(t, err) { + t.Cleanup(func() { + assert.True(t, called) + }) + return + } + + t.Fatalf("err: %s", err) + }) + } +} + +func TestNewCmdEditMultipleTimeEntries(t *testing.T) { + w := dto.Workspace{ID: "w"} + now := time.Now() + start1 := now + end1 := now.Add(1 * time.Hour) + start2 := now.Add(2 * time.Hour) + end2 := now.Add(3 * time.Hour) + + te1 := dto.TimeEntryImpl{ + WorkspaceID: w.ID, + ID: "teid1", + Description: "Entry 1", + ProjectID: "proj1", + TaskID: "task1", + TagIDs: []string{"tag1"}, + TimeInterval: dto.TimeInterval{ + Start: start1, + End: &end1, + }, + Billable: false, + UserID: "u", + } + + te2 := dto.TimeEntryImpl{ + WorkspaceID: w.ID, + ID: "teid2", + Description: "Entry 2", + ProjectID: "proj1", + TaskID: "task2", + TagIDs: []string{"tag2"}, + TimeInterval: dto.TimeInterval{ + Start: start2, + End: &end2, + }, + Billable: true, + UserID: "u", + } + + tts := []struct { + name string + args []string + timeEntries []dto.TimeEntryImpl + updateParams []api.UpdateTimeEntryParam + validateProject *dto.Project + err string + setup func(*mocks.MockClient) + }{ + { + name: "should fail when using --when with multiple entries", + args: []string{"teid1", "teid2", "-s", "09:00"}, + err: "--when and --when-to-close can only be used when editing a single time entry", + }, + { + name: "should fail when using --when-to-close with multiple entries", + args: []string{"teid1", "teid2", "-e", "18:00"}, + err: "--when and --when-to-close can only be used when editing a single time entry", + }, + { + name: "should set billable to true for multiple entries", + args: []string{"teid1", "teid2", "-b", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: true, + Description: te1.Description, + ProjectID: te1.ProjectID, + TaskID: te1.TaskID, + TagIDs: te1.TagIDs, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: true, + Description: te2.Description, + ProjectID: te2.ProjectID, + TaskID: te2.TaskID, + TagIDs: te2.TagIDs, + }, + }, + validateProject: &dto.Project{ID: te1.ProjectID}, + }, + { + name: "should set billable to false for multiple entries", + args: []string{"teid1", "teid2", "--not-billable", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: false, + Description: te1.Description, + ProjectID: te1.ProjectID, + TaskID: te1.TaskID, + TagIDs: te1.TagIDs, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: false, + Description: te2.Description, + ProjectID: te2.ProjectID, + TaskID: te2.TaskID, + TagIDs: te2.TagIDs, + }, + }, + validateProject: &dto.Project{ID: te1.ProjectID}, + }, + { + name: "should fail when using both billable and not-billable", + args: []string{"teid1", "teid2", "-b", "--not-billable", "-q"}, + setup: func(c *mocks.MockClient) { + c.EXPECT().GetTimeEntry(api.GetTimeEntryParam{ + Workspace: w.ID, TimeEntryID: "teid1", + }).Return(&te1, nil) + c.EXPECT().GetTimeEntry(api.GetTimeEntryParam{ + Workspace: w.ID, TimeEntryID: "teid2", + }).Return(&te2, nil) + }, + err: "can't be used together", + }, + { + name: "should update only description", + args: []string{"teid1", "teid2", "-d", "New Description", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: te1.Billable, + Description: "New Description", + ProjectID: te1.ProjectID, + TaskID: te1.TaskID, + TagIDs: te1.TagIDs, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: te2.Billable, + Description: "New Description", + ProjectID: te2.ProjectID, + TaskID: te2.TaskID, + TagIDs: te2.TagIDs, + }, + }, + validateProject: &dto.Project{ID: te1.ProjectID}, + }, + { + name: "should update only tags", + args: []string{"teid1", "teid2", "-T", "newtag", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: te1.Billable, + Description: te1.Description, + ProjectID: te1.ProjectID, + TaskID: te1.TaskID, + TagIDs: []string{"newtag"}, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: te2.Billable, + Description: te2.Description, + ProjectID: te2.ProjectID, + TaskID: te2.TaskID, + TagIDs: []string{"newtag"}, + }, + }, + validateProject: &dto.Project{ID: te1.ProjectID}, + }, + { + name: "should update only task", + args: []string{"teid1", "teid2", "--task", "newtask", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: te1.Billable, + Description: te1.Description, + ProjectID: te1.ProjectID, + TaskID: "newtask", + TagIDs: te1.TagIDs, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: te2.Billable, + Description: te2.Description, + ProjectID: te1.ProjectID, + TaskID: "newtask", + TagIDs: te2.TagIDs, + }, + }, + validateProject: &dto.Project{ID: te1.ProjectID}, + }, + { + name: "should clear task when changing project", + args: []string{"teid1", "teid2", "-p", "newproj", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: te1.Billable, + Description: te1.Description, + ProjectID: "newproj", + TaskID: "", + TagIDs: te1.TagIDs, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: te2.Billable, + Description: te2.Description, + ProjectID: "newproj", + TaskID: "", + TagIDs: te2.TagIDs, + }, + }, + validateProject: &dto.Project{ID: "newproj"}, + }, + { + name: "should clear both project and task when setting project to empty", + args: []string{"teid1", "teid2", "-p", "", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + updateParams: []api.UpdateTimeEntryParam{ + { + Workspace: te1.WorkspaceID, + TimeEntryID: te1.ID, + Start: te1.TimeInterval.Start, + End: te1.TimeInterval.End, + Billable: te1.Billable, + Description: te1.Description, + ProjectID: "", + TaskID: "", + TagIDs: te1.TagIDs, + }, + { + Workspace: te2.WorkspaceID, + TimeEntryID: te2.ID, + Start: te2.TimeInterval.Start, + End: te2.TimeInterval.End, + Billable: te2.Billable, + Description: te2.Description, + ProjectID: "", + TaskID: "", + TagIDs: te2.TagIDs, + }, + }, + }, + { + name: "should fail when changing task on entries with different projects", + args: []string{"teid1", "teid2", "--task", "newtask", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, + + func() dto.TimeEntryImpl { + t := te2 + t.ProjectID = "proj2" + return t + }(), + }, + validateProject: &dto.Project{ID: te1.ProjectID}, + err: "you are changing the task of the time entries, but not the project and some of them are not in the same project, please also set --project", + }, + { + name: "should fail when time entry is not found", + args: []string{"teid1", "teid2", "-q"}, + setup: func(c *mocks.MockClient) { + c.EXPECT().GetTimeEntry(api.GetTimeEntryParam{ + Workspace: w.ID, TimeEntryID: "teid1", + }).Return(&te1, nil) + c.EXPECT().GetTimeEntry(api.GetTimeEntryParam{ + Workspace: w.ID, TimeEntryID: "teid2", + }).Return(nil, errors.New("time entry not found")) + }, + err: "time entry not found", + }, + { + name: "should fail when update fails", + args: []string{"teid1", "teid2", "-d", "test", "-q"}, + timeEntries: []dto.TimeEntryImpl{te1, te2}, + validateProject: &dto.Project{ID: te1.ProjectID}, + setup: func(c *mocks.MockClient) { + c.EXPECT().UpdateTimeEntry(mock.Anything). + Return(dto.TimeEntryImpl{}, nil).Once() + c.EXPECT().UpdateTimeEntry(mock.Anything). + Return(dto.TimeEntryImpl{}, errors.New("update failed")).Once() + }, + err: "update failed", + }, + } + + for i := range tts { + tt := &tts[i] + t.Run(tt.name, func(t *testing.T) { + f := mocks.NewMockFactory(t) + + f.EXPECT().GetUserID().Return("u", nil) + f.EXPECT().GetWorkspaceID().Return(w.ID, nil) + + c := mocks.NewMockClient(t) + f.EXPECT().Client().Return(c, nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{AllowNameForID: false}) + + if len(tt.timeEntries) > 0 { + f.EXPECT().GetWorkspace().Return(w, nil) + + for i, te := range tt.timeEntries { + c.EXPECT().GetTimeEntry(api.GetTimeEntryParam{ + Workspace: w.ID, + TimeEntryID: te.ID, + }).Return(&tt.timeEntries[i], nil) + } + } + + if tt.validateProject != nil { + c.EXPECT().GetProject(api.GetProjectParam{ + Workspace: w.ID, + ProjectID: tt.validateProject.ID, + }).Return(tt.validateProject, nil) + } + + if tt.setup != nil { + tt.setup(c) + } + + if tt.err == "" { + for i := range tt.timeEntries { + tei := tt.timeEntries[i] + c.EXPECT().GetHydratedTimeEntry(api.GetTimeEntryParam{ + Workspace: w.ID, + TimeEntryID: tei.ID, + }).Return(&dto.TimeEntry{ID: tei.ID, WorkspaceID: w.ID}, nil) + } + + for _, p := range tt.updateParams { + c.EXPECT().UpdateTimeEntry(p).Return(dto.TimeEntryImpl{}, nil) + } + } + + cmd := edit.NewCmdEdit(f, func( + _ dto.TimeEntryImpl, _ io.Writer, _ util.OutputFlags) error { + return nil + }) + + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + out := bytes.NewBufferString("") + cmd.SetOut(out) + cmd.SetErr(out) + + cmd.SetArgs(tt.args) + _, err := cmd.ExecuteC() + + if tt.err != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.err) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/time-entry/timeentry.go b/pkg/cmd/time-entry/timeentry.go index ba5d7ef1..322269f6 100644 --- a/pkg/cmd/time-entry/timeentry.go +++ b/pkg/cmd/time-entry/timeentry.go @@ -7,7 +7,6 @@ import ( "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/clone" del "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/delete" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit" - em "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit-multipple" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/in" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/invoiced" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/manual" @@ -40,7 +39,6 @@ func NewCmdTimeEntry(f cmdutil.Factory) (cmds []*cobra.Command) { clone.NewCmdClone(f), edit.NewCmdEdit(f, rFn), - em.NewCmdEditMultiple(f), split.NewCmdSplit(f, rmFn),