diff --git a/docs/azdo_boards_iteration_project.md b/docs/azdo_boards_iteration_project.md index 298fdd66..80e8b005 100644 --- a/docs/azdo_boards_iteration_project.md +++ b/docs/azdo_boards_iteration_project.md @@ -5,6 +5,7 @@ Project-scoped iteration commands. ### Available commands * [azdo boards iteration project create](./azdo_boards_iteration_project_create.md) +* [azdo boards iteration project delete](./azdo_boards_iteration_project_delete.md) * [azdo boards iteration project list](./azdo_boards_iteration_project_list.md) ### ALIASES diff --git a/docs/azdo_boards_iteration_project_delete.md b/docs/azdo_boards_iteration_project_delete.md new file mode 100644 index 00000000..5179de3f --- /dev/null +++ b/docs/azdo_boards_iteration_project_delete.md @@ -0,0 +1,71 @@ +## Command `azdo boards iteration project delete` + +``` +azdo boards iteration project delete [ORGANIZATION/]PROJECT --path [flags] +``` + +Delete an iteration (sprint) from a project. The command prompts for +confirmation unless --yes is supplied. Use --reclassify-id to move any +work items to another node before deletion; the Azure DevOps REST API +rejects deletes while a node is still in use unless work items are +reclassified first. + + +### Options + + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `--path` `string` + + Path of the iteration to delete (under /Iteration, leading /Iteration stripped). + +* `-r`, `--reclassify-id` `int` + + ID of the target node to which work items should be moved before deletion. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `-y`, `--yes` + + Skip the confirmation prompt. + + +### ALIASES + +- `d` +- `del` +- `rm` + +### JSON Fields + +`deleted`, `path`, `reclassifyId` + +### Examples + +```bash +# Delete a top-level iteration +azdo boards iteration project delete Fabrikam --path "Sprint 1" --yes + +# Delete a nested iteration with a confirmation prompt +azdo boards iteration project delete Fabrikam --path "Release 2025/Sprint 1" + +# Reclassify work items to node 42 before deletion +azdo boards iteration project delete Fabrikam --path "Sprint 1" \ + --reclassify-id 42 --yes + +# Emit JSON +azdo boards iteration project delete Fabrikam --path "Sprint 1" --reclassify-id 42 --json +``` + +### See also + +* [azdo boards iteration project](./azdo_boards_iteration_project.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index ae96d598..1e499b9d 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -148,6 +148,25 @@ Aliases c, cr ``` +##### `azdo boards iteration project delete [ORGANIZATION/]PROJECT --path [flags]` + +Delete an iteration from a project. + +``` +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. + --path string Path of the iteration to delete (under /Iteration, leading /Iteration stripped). +-r, --reclassify-id int ID of the target node to which work items should be moved before deletion. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +-y, --yes Skip the confirmation prompt. +``` + +Aliases + +``` +d, del, rm +``` + ##### `azdo boards iteration project list [ORGANIZATION/]PROJECT [flags]` List iteration hierarchy for a project. diff --git a/internal/cmd/boards/iteration/project/delete/delete.go b/internal/cmd/boards/iteration/project/delete/delete.go new file mode 100644 index 00000000..d18d74f4 --- /dev/null +++ b/internal/cmd/boards/iteration/project/delete/delete.go @@ -0,0 +1,173 @@ +package delete + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type deleteOptions struct { + scopeArg string + path string + reclassifyID *int + yes bool + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &deleteOptions{} + + cmd := &cobra.Command{ + Use: "delete [ORGANIZATION/]PROJECT --path ", + Short: "Delete an iteration from a project.", + Long: heredoc.Doc(` + Delete an iteration (sprint) from a project. The command prompts for + confirmation unless --yes is supplied. Use --reclassify-id to move any + work items to another node before deletion; the Azure DevOps REST API + rejects deletes while a node is still in use unless work items are + reclassified first. + `), + Example: heredoc.Doc(` + # Delete a top-level iteration + azdo boards iteration project delete Fabrikam --path "Sprint 1" --yes + + # Delete a nested iteration with a confirmation prompt + azdo boards iteration project delete Fabrikam --path "Release 2025/Sprint 1" + + # Reclassify work items to node 42 before deletion + azdo boards iteration project delete Fabrikam --path "Sprint 1" \ + --reclassify-id 42 --yes + + # Emit JSON + azdo boards iteration project delete Fabrikam --path "Sprint 1" --reclassify-id 42 --json + `), + Aliases: []string{"d", "del", "rm"}, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runDelete(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.path, "path", "", "Path of the iteration to delete (under /Iteration, leading /Iteration stripped).") + util.NilIntFlag(cmd, &opts.reclassifyID, "reclassify-id", "r", "ID of the target node to which work items should be moved before deletion.") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt.") + _ = cmd.MarkFlagRequired("path") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "deleted", "path", "reclassifyId", + }) + + return cmd +} + +func runDelete(ctx util.CmdContext, opts *deleteOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if parts := strings.Split(strings.TrimSpace(opts.scopeArg), "/"); len(parts) > 2 { + return util.FlagErrorf("invalid project scope %q: expected [ORGANIZATION/]PROJECT", opts.scopeArg) + } + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + rawPath := strings.TrimSpace(opts.path) + if rawPath == "" { + return util.FlagErrorf("--path must not be empty") + } + nodePath, err := shared.BuildClassificationPath(scope.Project, true, "Iteration", rawPath) + if err != nil { + return util.FlagErrorf("invalid --path: %w", err) + } + if nodePath == "" { + return util.FlagErrorf("--path must reference a child of /Iteration, not the iteration root") + } + + if !opts.yes { + if !ios.CanPrompt() { + return util.FlagErrorf("--yes required when not running interactively") + } + ios.StopProgressIndicator() + prompter, err := ctx.Prompter() + if err != nil { + return err + } + prompt := fmt.Sprintf("Delete iteration %q from project %s/%s?", nodePath, scope.Organization, scope.Project) + confirmed, err := prompter.Confirm(prompt, false) + if err != nil { + return err + } + if !confirmed { + zap.L().Debug( + "iteration deletion canceled by user", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("path", nodePath), + ) + return util.ErrCancel + } + ios.StartProgressIndicator() + } + + zap.L().Debug( + "deleting iteration", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("path", nodePath), + ) + + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to get classification client: %w", err) + } + + args := workitemtracking.DeleteClassificationNodeArgs{ + Project: types.ToPtr(scope.Project), + StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), + Path: types.ToPtr(nodePath), + } + if opts.reclassifyID != nil { + args.ReclassifyId = opts.reclassifyID + } + + if err := wit.DeleteClassificationNode(ctx.Context(), args); err != nil { + return fmt.Errorf("failed to delete iteration: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + if opts.reclassifyID != nil { + return opts.exporter.Write(ios, map[string]any{ + "deleted": true, + "path": nodePath, + "reclassifyId": *opts.reclassifyID, + }) + } + return opts.exporter.Write(ios, map[string]any{ + "deleted": true, + "path": nodePath, + }) + } + + fmt.Fprintf(ios.Out, "Deleted iteration: %s\n", nodePath) + if opts.reclassifyID != nil { + fmt.Fprintf(ios.Out, "Reclassified work items to: %d\n", *opts.reclassifyID) + } + return nil +} diff --git a/internal/cmd/boards/iteration/project/delete/delete_test.go b/internal/cmd/boards/iteration/project/delete/delete_test.go new file mode 100644 index 00000000..9ffb83f7 --- /dev/null +++ b/internal/cmd/boards/iteration/project/delete/delete_test.go @@ -0,0 +1,412 @@ +package delete + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type fakeDeleteDeps struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + prompter *mocks.MockPrompter + stdout *bytes.Buffer +} + +func setupFakeDeps(t *testing.T, organization string, canPrompt bool) *fakeDeleteDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdinTTY(canPrompt) + io.SetStdoutTTY(canPrompt) + io.SetStderrTTY(canPrompt) + + deps := &fakeDeleteDeps{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + prompter: mocks.NewMockPrompter(ctrl), + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, nil).AnyTimes() + deps.cmd.EXPECT().Prompter().Return(deps.prompter, nil).AnyTimes() + + return deps +} + +func setupFakeDepsWithDefaultOrg(t *testing.T, defaultOrg string, canPrompt bool) *fakeDeleteDeps { + t.Helper() + + deps := setupFakeDeps(t, defaultOrg, canPrompt) + cfg := mocks.NewMockConfig(deps.ctrl) + auth := mocks.NewMockAuthConfig(deps.ctrl) + + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(defaultOrg, nil).AnyTimes() + + return deps +} + +func requireFlagError(t *testing.T, err error, substr string) { + t.Helper() + + require.Error(t, err) + var flagErr *util.FlagError + require.ErrorAs(t, err, &flagErr) + assert.Contains(t, err.Error(), substr) +} + +func TestNewCmd_RegistersAsDeleteLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + + assert.Equal(t, "delete", cmd.Name()) + assert.Equal(t, []string{"d", "del", "rm"}, cmd.Aliases) + assert.True(t, strings.HasPrefix(cmd.Use, "delete [ORGANIZATION/]PROJECT")) +} + +func TestNewCmd_PathFlagRequired(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{"Fabrikam"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "path") +} + +func TestRunDelete_EmptyPathFlag(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: " "} + + err := runDelete(deps.cmd, opts) + + requireFlagError(t, err, "--path must not be empty") +} + +func TestRunDelete_RootNode_Rejected(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Fabrikam/Iteration"} + + err := runDelete(deps.cmd, opts) + + requireFlagError(t, err, "--path must reference a child") +} + +func TestRunDelete_PathNormalizationStripsProjectAndIteration(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Fabrikam/Iteration/Release 2025/Sprint 1", yes: true} + var got workitemtracking.DeleteClassificationNodeArgs + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { + got = args + return nil + }, + ) + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + assert.Equal(t, "Release%202025/Sprint%201", *got.Path) +} + +func TestRunDelete_PathURLEscaping(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "My Sprint/Sub Sprint", yes: true} + var got workitemtracking.DeleteClassificationNodeArgs + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { + got = args + return nil + }, + ) + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + assert.Equal(t, "My%20Sprint/Sub%20Sprint", *got.Path) +} + +func TestRunDelete_ReclassifyId_Set(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", reclassifyID: types.ToPtr(42), yes: true} + var got workitemtracking.DeleteClassificationNodeArgs + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { + got = args + return nil + }, + ) + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + require.NotNil(t, got.ReclassifyId) + assert.Equal(t, 42, *got.ReclassifyId) +} + +func TestRunDelete_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeArg string + org string + project string + wantErr string + defaultOrg string + }{ + {name: "organization and project", scopeArg: "org/proj", org: "org", project: "proj"}, + {name: "project uses default organization", scopeArg: "proj", org: "default-org", project: "proj", defaultOrg: "default-org"}, + {name: "too many segments", scopeArg: "org/proj/extra", wantErr: "expected"}, + {name: "empty scope", scopeArg: "", wantErr: "expected"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var deps *fakeDeleteDeps + if tc.defaultOrg != "" { + deps = setupFakeDepsWithDefaultOrg(t, tc.defaultOrg, false) + } else { + deps = setupFakeDeps(t, tc.org, false) + } + opts := &deleteOptions{scopeArg: tc.scopeArg, path: "Sprint 1", yes: true} + + if tc.wantErr != "" { + err := runDelete(deps.cmd, opts) + requireFlagError(t, err, tc.wantErr) + return + } + + var got workitemtracking.DeleteClassificationNodeArgs + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { + got = args + return nil + }, + ) + err := runDelete(deps.cmd, opts) + require.NoError(t, err) + assert.Equal(t, tc.project, *got.Project) + }) + } +} + +func TestRunDelete_ClientFactoryError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + clientFact.EXPECT().WorkItemTracking(gomock.Any(), "org").Return(nil, errors.New("boom")) + + err := runDelete(cmd, &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get classification client") +} + +func TestRunDelete_SDKError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true} + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(errors.New("boom")) + + err := runDelete(deps.cmd, opts) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete iteration") +} + +func TestRunDelete_YesFlag_SkipsPrompt(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", true) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true} + + deps.prompter.EXPECT().Confirm(gomock.Any(), false).Times(0) + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) + + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) +} + +func TestRunDelete_ConfirmationPrompt_Yes(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", true) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"} + + deps.prompter.EXPECT().Confirm("Delete iteration \"Sprint%201\" from project org/Fabrikam?", false).Return(true, nil) + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) + + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) +} + +func TestRunDelete_ConfirmationPrompt_No(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", true) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"} + + deps.prompter.EXPECT().Confirm(gomock.Any(), false).Return(false, nil) + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Times(0) + + err := runDelete(deps.cmd, opts) + + require.ErrorIs(t, err, util.ErrCancel) +} + +func TestRunDelete_NonTTY_NoYes_ReturnsError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"} + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Times(0) + + err := runDelete(deps.cmd, opts) + + requireFlagError(t, err, "--yes required when not running interactively") +} + +func TestRunDelete_DefaultOutput(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true} + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) + + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + assert.Equal(t, "Deleted iteration: Sprint%201\n", deps.stdout.String()) +} + +func TestRunDelete_DefaultOutput_WithReclassify(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", reclassifyID: types.ToPtr(42), yes: true} + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) + + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + assert.Equal(t, "Deleted iteration: Sprint%201\nReclassified work items to: 42\n", deps.stdout.String()) +} + +func TestRunDelete_JSONOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reclassifyID *int + }{ + {name: "without reclassify"}, + {name: "with reclassify", reclassifyID: types.ToPtr(42)}, + {name: "with explicit zero", reclassifyID: types.ToPtr(0)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org", false) + opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", reclassifyID: tc.reclassifyID, yes: true, exporter: util.NewJSONExporter()} + + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) + + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &got)) + assert.Equal(t, true, got["deleted"]) + assert.Equal(t, "Sprint%201", got["path"]) + if tc.reclassifyID == nil { + assert.NotContains(t, got, "reclassifyId") + return + } + assert.Equal(t, float64(*tc.reclassifyID), got["reclassifyId"]) + }) + } +} + +func TestRunDelete_OrganizationFromConfigDefault(t *testing.T) { + t.Parallel() + + deps := setupFakeDepsWithDefaultOrg(t, "default-org", false) + opts := &deleteOptions{scopeArg: "Fabrikam", path: "Sprint 1", yes: true} + var got workitemtracking.DeleteClassificationNodeArgs + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { + got = args + return nil + }, + ) + + err := runDelete(deps.cmd, opts) + + require.NoError(t, err) + assert.Equal(t, "Fabrikam", *got.Project) + assert.Equal(t, "Sprint%201", *got.Path) + assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *got.StructureGroup) + assert.Equal(t, "iterations", string(*got.StructureGroup)) + assert.Nil(t, got.ReclassifyId) +} diff --git a/internal/cmd/boards/iteration/project/project.go b/internal/cmd/boards/iteration/project/project.go index f476ee4b..97d7a7ab 100644 --- a/internal/cmd/boards/iteration/project/project.go +++ b/internal/cmd/boards/iteration/project/project.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/create" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/delete" "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/list" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -24,6 +25,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(create.NewCmd(ctx)) + cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) return cmd diff --git a/internal/cmd/util/flags.go b/internal/cmd/util/flags.go index 0f4c980d..9e193a0a 100644 --- a/internal/cmd/util/flags.go +++ b/internal/cmd/util/flags.go @@ -24,6 +24,12 @@ func NilBoolFlag(cmd *cobra.Command, p **bool, name string, shorthand string, us return f } +// NilIntFlag defines a new flag with an int pointer receiver. This is useful for differentiating +// between the flag being explicitly set to 0 and the flag not being passed at all. +func NilIntFlag(cmd *cobra.Command, p **int, name string, shorthand string, usage string) *pflag.Flag { + return cmd.Flags().VarPF(newIntValue(p), name, shorthand, usage) +} + // StringEnumFlag defines a new string flag that only allows values listed in options. func StringEnumFlag(cmd *cobra.Command, p *string, name, shorthand, defaultValue string, options []string, usage string) *pflag.Flag { *p = defaultValue @@ -134,6 +140,31 @@ func (b *boolValue) IsBoolFlag() bool { return true } +type intValue struct { + int **int +} + +func newIntValue(p **int) *intValue { + return &intValue{p} +} + +func (i *intValue) Set(value string) error { + v, err := strconv.Atoi(value) + *i.int = &v + return err +} + +func (i *intValue) String() string { + if i.int == nil || *i.int == nil { + return "" + } + return strconv.Itoa(**i.int) +} + +func (i *intValue) Type() string { + return "int" +} + type enumValue struct { string *string options []string diff --git a/internal/cmd/util/flags_test.go b/internal/cmd/util/flags_test.go index 67619c76..2efb9873 100644 --- a/internal/cmd/util/flags_test.go +++ b/internal/cmd/util/flags_test.go @@ -76,6 +76,40 @@ func TestNilBoolFlag(t *testing.T) { } } +func TestNilIntFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want *int + }{ + {name: "omitted", args: nil, want: nil}, + {name: "set", args: []string{"--count", "42"}, want: ptr(42)}, + {name: "set zero", args: []string{"--count", "0"}, want: ptr(0)}, + {name: "short zero", args: []string{"-c", "0"}, want: ptr(0)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got *int + cmd := &cobra.Command{Use: "test"} + NilIntFlag(cmd, &got, "count", "c", "count") + + err := cmd.ParseFlags(tt.args) + require.NoError(t, err) + + if tt.want == nil { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } +} + func TestStringEnumFlag(t *testing.T) { t.Parallel() diff --git a/internal/util/stringBuilder.go b/internal/util/stringBuilder.go index 93dcccf6..8df6da2e 100644 --- a/internal/util/stringBuilder.go +++ b/internal/util/stringBuilder.go @@ -32,7 +32,7 @@ type StringBuilder struct { func NewStringBuilder() *StringBuilder { s := rand.NewSource(time.Now().UnixNano()) return &StringBuilder{ - rand: rand.New(s), + rand: rand.New(s), //nolint:gosec } }