From 8518a3f14e2bf8273547639a099e058faddc8f98 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:36:51 +0000 Subject: [PATCH 1/3] feat: Implement `azdo pipelines folder delete` command Fixes #264 --- .../cmd/pipelines/folder/delete/delete.go | 118 ++++++++++++++++++ internal/cmd/pipelines/folder/folder.go | 2 + 2 files changed, 120 insertions(+) create mode 100644 internal/cmd/pipelines/folder/delete/delete.go diff --git a/internal/cmd/pipelines/folder/delete/delete.go b/internal/cmd/pipelines/folder/delete/delete.go new file mode 100644 index 00000000..31dc427a --- /dev/null +++ b/internal/cmd/pipelines/folder/delete/delete.go @@ -0,0 +1,118 @@ +package delete + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type opts struct { + targetArg string + yes bool +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "delete [ORG:]PROJECT/PATH", + Short: "Delete a folder.", + Aliases: []string{"d", "del", "rm"}, + Long: heredoc.Doc(` + Delete a build definition folder at PATH under PROJECT. + + Mirrors 'az pipelines folder delete'. The folder, all build definitions + in it, and all builds for those definitions are deleted. This action is + not reversible. + `), + Example: heredoc.Doc(` + # Delete a folder in the default organization + azdo pipelines folder delete Fabrikam/External/CI --yes + + # Delete a folder in a specific organization + azdo pipelines folder delete myorg:Fabrikam/External/CI + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runDelete(ctx, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt.") + + return cmd +} + +func runDelete(cmdCtx util.CmdContext, opts *opts) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectPathTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + path := strings.Join(scope.Targets, "/") + + zap.L().Debug( + "resolved folder delete target", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("path", path), + ) + + if !opts.yes { + if !ios.CanPrompt() { + return util.FlagErrorf("--yes required when not running interactively") + } + ios.StopProgressIndicator() + prompter, err := cmdCtx.Prompter() + if err != nil { + return err + } + confirmed, err := prompter.Confirm("This will delete all pipelines in this folder. Are you sure you want to delete this folder?", false) + if err != nil { + return err + } + if !confirmed { + zap.L().Debug("folder deletion canceled by user", zap.String("path", path)) + return util.ErrCancel + } + ios.StartProgressIndicator() + } + + client, err := cmdCtx.ClientFactory().Build(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create build client: %w", err) + } + + if err := client.DeleteFolder(cmdCtx.Context(), build.DeleteFolderArgs{ + Project: types.ToPtr(scope.Project), + Path: types.ToPtr(path), + }); err != nil { + return fmt.Errorf("failed to delete folder %s: %w", path, err) + } + + zap.L().Debug( + "folder deleted", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("path", path), + ) + + ios.StopProgressIndicator() + + fmt.Fprintf(ios.Out, "Deleted folder %s/%s\n", scope.Project, path) + return nil +} diff --git a/internal/cmd/pipelines/folder/folder.go b/internal/cmd/pipelines/folder/folder.go index d5e7c7f6..8f2ae397 100644 --- a/internal/cmd/pipelines/folder/folder.go +++ b/internal/cmd/pipelines/folder/folder.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/create" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/delete" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/list" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -21,6 +22,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 } From 5f12bb647d60ee305ed991c0b73419fedc1889ae Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:37:12 +0000 Subject: [PATCH 2/3] test: add unit tests for pipelines folder delete command --- .../pipelines/folder/delete/delete_test.go | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 internal/cmd/pipelines/folder/delete/delete_test.go diff --git a/internal/cmd/pipelines/folder/delete/delete_test.go b/internal/cmd/pipelines/folder/delete/delete_test.go new file mode 100644 index 00000000..964fcd0e --- /dev/null +++ b/internal/cmd/pipelines/folder/delete/delete_test.go @@ -0,0 +1,234 @@ +package delete + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "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" +) + +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + buildCli *mocks.MockBuildClient + prompter *mocks.MockPrompter + config *mocks.MockConfig + auth *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func newDependencies(t *testing.T, organization string, canPrompt bool) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdinTTY(canPrompt) + io.SetStdoutTTY(canPrompt) + io.SetStderrTTY(canPrompt) + + deps := &dependencies{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + buildCli: mocks.NewMockBuildClient(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.cmd.EXPECT().Prompter().Return(deps.prompter, nil).AnyTimes() + if organization != "" { + deps.clientFact.EXPECT().Build(gomock.Any(), organization).Return(deps.buildCli, nil).AnyTimes() + } + + return deps +} + +func (d *dependencies) setupDefaultOrg(org string) { + d.config = mocks.NewMockConfig(d.ctrl) + d.auth = mocks.NewMockAuthConfig(d.ctrl) + d.cmd.EXPECT().Config().Return(d.config, nil).AnyTimes() + d.config.EXPECT().Authentication().Return(d.auth).AnyTimes() + d.auth.EXPECT().GetDefaultOrganization().Return(org, nil).AnyTimes() +} + +func TestNewCmd_delete(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "delete [ORG:]PROJECT/PATH", cmd.Use) + assert.ElementsMatch(t, []string{"d", "del", "rm"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) + require.NoError(t, cmd.Args(cmd, []string{"Fabrikam/External"})) + assert.Error(t, cmd.Args(cmd, []string{"Fabrikam/External", "Extra"})) + + f := cmd.Flags() + assert.NotNil(t, f.Lookup("yes")) + assert.Nil(t, f.Lookup("json")) +} + +func TestNewCmd_missingPath(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestRunDelete_success_withYes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + defaultOrg string + expectedOrg string + expectedProject string + expectedPath string + expectedOutput string + }{ + { + name: "implicit org", + targetArg: "MyProject/Foo", + defaultOrg: "myorg", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "Foo", + expectedOutput: "Deleted folder MyProject/Foo\n", + }, + { + name: "explicit org nested path", + targetArg: "myorg:MyProject/External/CI", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "External/CI", + expectedOutput: "Deleted folder MyProject/External/CI\n", + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, tc.expectedOrg, false) + if tc.defaultOrg != "" { + deps.setupDefaultOrg(tc.defaultOrg) + } + + deps.buildCli.EXPECT().DeleteFolder(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.DeleteFolderArgs) error { + require.NotNil(t, args.Project) + assert.Equal(t, tc.expectedProject, *args.Project) + require.NotNil(t, args.Path) + assert.Equal(t, tc.expectedPath, *args.Path) + return nil + }, + ) + + err := runDelete(deps.cmd, &opts{targetArg: tc.targetArg, yes: true}) + require.NoError(t, err) + assert.Equal(t, tc.expectedOutput, deps.stdout.String()) + }) + } +} + +func TestRunDelete_success_confirmed(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().DeleteFolder(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.DeleteFolderArgs) error { + require.NotNil(t, args.Project) + assert.Equal(t, "MyProject", *args.Project) + require.NotNil(t, args.Path) + assert.Equal(t, "Foo", *args.Path) + return nil + }, + ) + deps.prompter.EXPECT().Confirm( + "This will delete all pipelines in this folder. Are you sure you want to delete this folder?", + false, + ).Return(true, nil) + + err := runDelete(deps.cmd, &opts{targetArg: "MyProject/Foo"}) + require.NoError(t, err) + assert.Equal(t, "Deleted folder MyProject/Foo\n", deps.stdout.String()) +} + +func TestRunDelete_cancelled(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.prompter.EXPECT().Confirm(gomock.Any(), false).Return(false, nil) + + err := runDelete(deps.cmd, &opts{targetArg: "MyProject/Foo"}) + require.ErrorIs(t, err, util.ErrCancel) + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_nonInteractive(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + + err := runDelete(deps.cmd, &opts{targetArg: "MyProject/Foo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--yes required when not running interactively") +} + +func TestRunDelete_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().DeleteFolder(gomock.Any(), gomock.Any()).Return(errors.New("boom")) + + err := runDelete(deps.cmd, &opts{targetArg: "MyProject/Foo", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete folder Foo: boom") +} + +func TestRunDelete_missingDefaultOrganization(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "", false) + deps.setupDefaultOrg("") + + err := runDelete(deps.cmd, &opts{targetArg: "MyProject/Foo", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no organization specified") +} + +func TestRunDelete_clientError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "", false) + deps.setupDefaultOrg("myorg") + deps.clientFact.EXPECT().Build(gomock.Any(), "myorg").Return(nil, fmt.Errorf("no client")) + + err := runDelete(deps.cmd, &opts{targetArg: "MyProject/Foo", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create build client: no client") +} From ec13bc187bd0b62fc3614f91304e302da72c3fc3 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:37:53 +0000 Subject: [PATCH 3/3] docs: extend azdo pipelines folder reference with delete support --- docs/azdo_help_reference.md | 14 ++++++++++ docs/azdo_pipelines_folder.md | 1 + docs/azdo_pipelines_folder_delete.md | 40 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 docs/azdo_pipelines_folder_delete.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 60b38de8..6309af40 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -434,6 +434,20 @@ Aliases c, cr ``` +#### `azdo pipelines folder delete [ORG:]PROJECT/PATH [flags]` + +Delete a folder. + +``` +-y, --yes Skip the confirmation prompt. +``` + +Aliases + +``` +d, del, rm +``` + #### `azdo pipelines folder list [ORG:]PROJECT [flags]` List folders. diff --git a/docs/azdo_pipelines_folder.md b/docs/azdo_pipelines_folder.md index 909ea5d9..6f16a688 100644 --- a/docs/azdo_pipelines_folder.md +++ b/docs/azdo_pipelines_folder.md @@ -7,6 +7,7 @@ and organize pipeline definitions. ### Available commands * [azdo pipelines folder create](./azdo_pipelines_folder_create.md) +* [azdo pipelines folder delete](./azdo_pipelines_folder_delete.md) * [azdo pipelines folder list](./azdo_pipelines_folder_list.md) ### ALIASES diff --git a/docs/azdo_pipelines_folder_delete.md b/docs/azdo_pipelines_folder_delete.md new file mode 100644 index 00000000..b879e8c6 --- /dev/null +++ b/docs/azdo_pipelines_folder_delete.md @@ -0,0 +1,40 @@ +## Command `azdo pipelines folder delete` + +``` +azdo pipelines folder delete [ORG:]PROJECT/PATH [flags] +``` + +Delete a build definition folder at PATH under PROJECT. + +Mirrors 'az pipelines folder delete'. The folder, all build definitions +in it, and all builds for those definitions are deleted. This action is +not reversible. + + +### Options + + +* `-y`, `--yes` + + Skip the confirmation prompt. + + +### ALIASES + +- `d` +- `del` +- `rm` + +### Examples + +```bash +# Delete a folder in the default organization +azdo pipelines folder delete Fabrikam/External/CI --yes + +# Delete a folder in a specific organization +azdo pipelines folder delete myorg:Fabrikam/External/CI +``` + +### See also + +* [azdo pipelines folder](./azdo_pipelines_folder.md)