From e4cf2e665c2833c367c7a5f486aabc09dee27f7a Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 13:46:15 +0000 Subject: [PATCH 1/4] =?UTF-8?q?refactor(pipelines):=20=E2=9A=99=EF=B8=8F?= =?UTF-8?q?=20extract=20ResolvePipelineDefinition=20to=20shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move pipeline target resolution (by positive ID or definition name) into a reusable shared package. Update the pipelines show command to delegate to the new helper and add unit tests covering ID validation, name lookup, ambiguity, not-found, and query-error cases. --- internal/cmd/pipelines/shared/resolve.go | 50 ++++++++ internal/cmd/pipelines/shared/resolve_test.go | 121 ++++++++++++++++++ internal/cmd/pipelines/show/show.go | 28 +--- 3 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 internal/cmd/pipelines/shared/resolve.go create mode 100644 internal/cmd/pipelines/shared/resolve_test.go diff --git a/internal/cmd/pipelines/shared/resolve.go b/internal/cmd/pipelines/shared/resolve.go new file mode 100644 index 00000000..0e463dd9 --- /dev/null +++ b/internal/cmd/pipelines/shared/resolve.go @@ -0,0 +1,50 @@ +package shared + +import ( + "fmt" + "strconv" + "strings" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +// ResolvePipelineDefinition resolves a pipeline target by positive ID or definition name. +func ResolvePipelineDefinition(cmdCtx util.CmdContext, client build.Client, project, raw string) (int, error) { + target := strings.TrimSpace(raw) + if target == "" { + return 0, util.FlagErrorf("pipeline target cannot be empty") + } + if strings.TrimSpace(project) == "" { + return 0, fmt.Errorf("project is required to resolve pipeline definitions") + } + + if id, err := strconv.Atoi(target); err == nil { + if id <= 0 { + return 0, fmt.Errorf("pipeline id must be greater than zero: %q", target) + } + return id, nil + } + + definitions, err := client.GetDefinitions(cmdCtx.Context(), build.GetDefinitionsArgs{ + Project: types.ToPtr(project), + Name: types.ToPtr(target), + }) + if err != nil { + return 0, fmt.Errorf("failed to query pipeline definitions: %w", err) + } + if definitions == nil || len(definitions.Value) == 0 { + return 0, fmt.Errorf("pipeline %q not found", target) + } + if len(definitions.Value) > 1 { + return 0, fmt.Errorf("pipeline %q is ambiguous: %d matches found", target, len(definitions.Value)) + } + + id := types.GetValue(definitions.Value[0].Id, 0) + if id <= 0 { + return 0, fmt.Errorf("pipeline %q returned empty id", target) + } + return id, nil +} diff --git a/internal/cmd/pipelines/shared/resolve_test.go b/internal/cmd/pipelines/shared/resolve_test.go new file mode 100644 index 00000000..87c68242 --- /dev/null +++ b/internal/cmd/pipelines/shared/resolve_test.go @@ -0,0 +1,121 @@ +package shared + +import ( + "context" + "errors" + "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/mocks" + "github.com/tmeckel/azdo-cli/internal/types" +) + +func TestResolvePipelineDefinition_PositiveID(t *testing.T) { + t.Parallel() + + id, err := ResolvePipelineDefinition(nil, nil, "Fabrikam", "42") + + require.NoError(t, err) + assert.Equal(t, 42, id) +} + +func TestResolvePipelineDefinition_RejectsNonPositiveID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + }{ + {name: "zero", raw: "0"}, + {name: "negative", raw: "-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := ResolvePipelineDefinition(nil, nil, "Fabrikam", tt.raw) + + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline id must be greater than zero") + }) + } +} + +func TestResolvePipelineDefinition_NameAmbiguous(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockBuildClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).Times(1) + client.EXPECT().GetDefinitions(gomock.Any(), build.GetDefinitionsArgs{ + Project: types.ToPtr("Fabrikam"), + Name: types.ToPtr("My Pipeline"), + }).Return(&build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: types.ToPtr(42), Name: types.ToPtr("My Pipeline")}, + {Id: types.ToPtr(99), Name: types.ToPtr("My Pipeline")}, + }, + }, nil) + + _, err := ResolvePipelineDefinition(ctx, client, "Fabrikam", "My Pipeline") + + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") +} + +func TestResolvePipelineDefinition_NameNotFound(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockBuildClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).Times(1) + client.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return(&build.GetDefinitionsResponseValue{}, nil) + + _, err := ResolvePipelineDefinition(ctx, client, "Fabrikam", "Ghost") + + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestResolvePipelineDefinition_QueryError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockBuildClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).Times(1) + client.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return(nil, errors.New("lookup failed")) + + _, err := ResolvePipelineDefinition(ctx, client, "Fabrikam", "My Pipeline") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to query pipeline definitions") +} + +func TestResolvePipelineDefinition_EmptyID(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockBuildClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).Times(1) + client.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return(&build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{{Name: types.ToPtr("My Pipeline")}}, + }, nil) + + _, err := ResolvePipelineDefinition(ctx, client, "Fabrikam", "My Pipeline") + + require.Error(t, err) + assert.Contains(t, err.Error(), "returned empty id") +} diff --git a/internal/cmd/pipelines/show/show.go b/internal/cmd/pipelines/show/show.go index ef58ed7c..620c3efa 100644 --- a/internal/cmd/pipelines/show/show.go +++ b/internal/cmd/pipelines/show/show.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/shared" "github.com/tmeckel/azdo-cli/internal/cmd/util" "github.com/tmeckel/azdo-cli/internal/template" "github.com/tmeckel/azdo-cli/internal/types" @@ -93,32 +94,9 @@ func runShow(ctx util.CmdContext, opts *showOptions) error { zap.String("pipeline", scope.Targets[0]), ) - raw := scope.Targets[0] - pipelineID, err := strconv.Atoi(raw) - if err == nil && pipelineID <= 0 { - return fmt.Errorf("pipeline id must be greater than zero: %q", raw) - } + pipelineID, err := shared.ResolvePipelineDefinition(ctx, buildClient, scope.Project, scope.Targets[0]) if err != nil { - defs, err := buildClient.GetDefinitions(ctx.Context(), build.GetDefinitionsArgs{ - Project: types.ToPtr(scope.Project), - Name: types.ToPtr(raw), - }) - if err != nil { - return fmt.Errorf("failed to query pipeline definitions: %w", err) - } - - if defs == nil || len(defs.Value) == 0 { - return fmt.Errorf("pipeline %q not found", raw) - } - - if len(defs.Value) > 1 { - return fmt.Errorf("pipeline %q is ambiguous: %d matches found", raw, len(defs.Value)) - } - - pipelineID = types.GetValue(defs.Value[0].Id, 0) - if pipelineID <= 0 { - return fmt.Errorf("pipeline %q returned empty id", raw) - } + return err } logger.Debug("fetching pipeline definition", zap.Int("pipelineId", pipelineID)) From 6e9ec4ea214594c65b45fac8d6355c74cef4525f Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 13:46:43 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(pipelines):=20=E2=9C=A8=20add=20pipeli?= =?UTF-8?q?ne=20delete=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/pipelines/delete/delete.go | 129 ++++++++++++++++++++++++ internal/cmd/pipelines/pipelines.go | 7 ++ 2 files changed, 136 insertions(+) create mode 100644 internal/cmd/pipelines/delete/delete.go diff --git a/internal/cmd/pipelines/delete/delete.go b/internal/cmd/pipelines/delete/delete.go new file mode 100644 index 00000000..abf00285 --- /dev/null +++ b/internal/cmd/pipelines/delete/delete.go @@ -0,0 +1,129 @@ +package delete + +import ( + "fmt" + + "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/pipelines/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type deleteOptions struct { + targetArg string + yes bool +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &deleteOptions{} + + cmd := &cobra.Command{ + Use: "delete [ORGANIZATION/]PROJECT/PIPELINE", + Short: "Delete a pipeline definition", + Long: heredoc.Doc(` + Delete a pipeline definition by ID or name. + + The command prompts for confirmation unless --yes is supplied. + `), + Example: heredoc.Doc(` + # Delete a pipeline by ID using the default organization + azdo pipelines delete Fabrikam/42 --yes + + # Delete a pipeline by name + azdo pipelines delete 'myorg/Fabrikam/My Pipeline' + + # Delete with confirmation + azdo pipelines delete Fabrikam/MyPipeline + `), + Aliases: []string{ + "d", + "del", + "rm", + }, + Args: util.ExactArgs(1, "pipeline target is required"), + 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 *deleteOptions) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + buildClient, err := cmdCtx.ClientFactory().Build(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Build client: %w", err) + } + + pipelineID, err := shared.ResolvePipelineDefinition(cmdCtx, buildClient, scope.Project, scope.Targets[0]) + if err != nil { + return err + } + + zap.L().Debug( + "resolved pipeline definition", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("input", scope.Targets[0]), + zap.Int("pipelineId", pipelineID), + ) + + 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("Are you sure you want to delete this pipeline?", false) + if err != nil { + return err + } + if !confirmed { + zap.L().Debug("pipeline deletion canceled by user", zap.Int("pipelineId", pipelineID)) + return util.ErrCancel + } + ios.StartProgressIndicator() + } + + if err := buildClient.DeleteDefinition(cmdCtx.Context(), build.DeleteDefinitionArgs{ + Project: types.ToPtr(scope.Project), + DefinitionId: types.ToPtr(pipelineID), + }); err != nil { + return fmt.Errorf("failed to delete pipeline %d: %w", pipelineID, err) + } + + zap.L().Debug( + "pipeline definition deleted", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("pipelineId", pipelineID), + ) + + ios.StopProgressIndicator() + + // ponytail: SDK delete returns no resource, so keep stdout text only. + fmt.Fprintf(ios.Out, "Pipeline %d was deleted successfully.\n", pipelineID) + return nil +} diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index 7a213923..5fd0ee04 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -1,8 +1,10 @@ package pipelines import ( + "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/delete" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/list" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/pool" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs" @@ -16,8 +18,13 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Use: "pipelines", Short: "Manage Azure DevOps pipelines", Aliases: []string{"p"}, + Example: heredoc.Doc(` + # Delete a pipeline definition + azdo pipelines delete Fabrikam/42 --yes + `), } + cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(runs.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) From 197337610d6c7b6aa90ffef41d6c999bbb653ac9 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 13:52:43 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test(pipelines):=20=F0=9F=A7=AA=20add=20tes?= =?UTF-8?q?ts=20for=20delete=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/pipelines/delete/delete_test.go | 269 +++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 internal/cmd/pipelines/delete/delete_test.go diff --git a/internal/cmd/pipelines/delete/delete_test.go b/internal/cmd/pipelines/delete/delete_test.go new file mode 100644 index 00000000..53046a23 --- /dev/null +++ b/internal/cmd/pipelines/delete/delete_test.go @@ -0,0 +1,269 @@ +package delete + +import ( + "bytes" + "context" + "errors" + "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" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type deleteDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + buildClient *mocks.MockBuildClient + prompter *mocks.MockPrompter + stdout *bytes.Buffer + t *testing.T +} + +func setupDeleteDeps(t *testing.T, organization string) *deleteDeps { + return setupDeleteDepsWithPrompt(t, organization, true) +} + +func setupDeleteDepsWithPrompt(t *testing.T, organization string, canPrompt bool) *deleteDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdinTTY(canPrompt) + io.SetStdoutTTY(canPrompt) + io.SetStderrTTY(canPrompt) + + deps := &deleteDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + buildClient: mocks.NewMockBuildClient(ctrl), + prompter: mocks.NewMockPrompter(ctrl), + stdout: out, + t: t, + } + + 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() + deps.clientFact.EXPECT().Build(gomock.Any(), organization).Return(deps.buildClient, nil).AnyTimes() + + return deps +} + +func setupDefaultOrgDeps(t *testing.T, organization string) *deleteDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + cfg := mocks.NewMockConfig(ctrl) + authCfg := mocks.NewMockAuthConfig(ctrl) + deps := &deleteDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + buildClient: mocks.NewMockBuildClient(ctrl), + prompter: mocks.NewMockPrompter(ctrl), + stdout: out, + t: t, + } + + 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() + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(authCfg).AnyTimes() + authCfg.EXPECT().GetDefaultOrganization().Return(organization, nil).AnyTimes() + deps.clientFact.EXPECT().Build(gomock.Any(), organization).Return(deps.buildClient, nil).AnyTimes() + + return deps +} + +func TestNewCmd_RegistersAsDeleteLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + + assert.Equal(t, "delete [ORGANIZATION/]PROJECT/PIPELINE", cmd.Use) + assert.ElementsMatch(t, []string{"d", "del", "rm"}, cmd.Aliases) + require.NotNil(t, cmd.RunE) + assert.Nil(t, cmd.Flags().Lookup("json")) +} + +func TestNewCmd_RequiresOneArg(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline target is required") +} + +func TestRunDelete_ByPositiveID(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + deps.expectDeleteDefinition("Fabrikam", 42, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--yes"}) + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, "Pipeline 42 was deleted successfully.\n", deps.stdout.String()) +} + +func TestRunDelete_ByName(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + deps.expectGetDefinitions("Fabrikam", "My Pipeline", &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{{Id: types.ToPtr(42), Name: types.ToPtr("My Pipeline")}}, + }, nil) + deps.expectDeleteDefinition("Fabrikam", 42, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/My Pipeline"}) + deps.prompter.EXPECT().Confirm("Are you sure you want to delete this pipeline?", false).Return(true, nil) + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, "Pipeline 42 was deleted successfully.\n", deps.stdout.String()) +} + +func TestRunDelete_DefaultsToConfiguredOrganization(t *testing.T) { + t.Parallel() + deps := setupDefaultOrgDeps(t, "DefaultOrg") + deps.expectDeleteDefinition("Fabrikam", 42, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"Fabrikam/42", "--yes"}) + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, "Pipeline 42 was deleted successfully.\n", deps.stdout.String()) +} + +func TestRunDelete_RejectsNonPositiveNumericID(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/0", "--yes"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline id must be greater than zero") + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_NameNotFound(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + deps.expectGetDefinitions("Fabrikam", "Ghost", &build.GetDefinitionsResponseValue{}, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/Ghost", "--yes"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_NameAmbiguous(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + deps.expectGetDefinitions("Fabrikam", "SameName", &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: types.ToPtr(1), Name: types.ToPtr("SameName")}, + {Id: types.ToPtr(2), Name: types.ToPtr("SameName")}, + }, + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/SameName", "--yes"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_RequiresYesWhenNotInteractive(t *testing.T) { + t.Parallel() + deps := setupDeleteDepsWithPrompt(t, "MyOrg", false) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "--yes required when not running interactively") + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_ConfirmationCanceled(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + deps.prompter.EXPECT().Confirm("Are you sure you want to delete this pipeline?", false).Return(false, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42"}) + err := cmd.Execute() + + require.ErrorIs(t, err, util.ErrCancel) + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_PropagatesDeleteError(t *testing.T) { + t.Parallel() + deps := setupDeleteDeps(t, "MyOrg") + deps.expectDeleteDefinition("Fabrikam", 42, errors.New("API error")) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--yes"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete pipeline 42: API error") +} + +func (d *deleteDeps) expectGetDefinitions(project, name string, resp *build.GetDefinitionsResponseValue, err error) { + d.t.Helper() + d.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(d.t, args.Project) + require.Equal(d.t, project, *args.Project) + require.NotNil(d.t, args.Name) + require.Equal(d.t, name, *args.Name) + return resp, err + }, + ).Times(1) +} + +func (d *deleteDeps) expectDeleteDefinition(project string, id int, err error) { + d.t.Helper() + d.buildClient.EXPECT().DeleteDefinition(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.DeleteDefinitionArgs) error { + require.NotNil(d.t, args.Project) + require.Equal(d.t, project, *args.Project) + require.NotNil(d.t, args.DefinitionId) + require.Equal(d.t, id, *args.DefinitionId) + return err + }, + ).Times(1) +} From 2ec1e57e0792261e064dfe1b54388b62930054ba Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 14:00:02 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(pipelines):=20=F0=9F=93=84=20add=20doc?= =?UTF-8?q?umentation=20for=20pipelines=20delete=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_help_reference.md | 14 ++++++++++++ docs/azdo_pipelines.md | 8 +++++++ docs/azdo_pipelines_delete.md | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 docs/azdo_pipelines_delete.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index f4468280..272e6168 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -289,6 +289,20 @@ Aliases view, status ``` +### `azdo pipelines delete [ORGANIZATION/]PROJECT/PIPELINE [flags]` + +Delete a pipeline definition + +``` +-y, --yes Skip the confirmation prompt. +``` + +Aliases + +``` +d, del, rm +``` + ### `azdo pipelines list [ORGANIZATION/]PROJECT [flags]` List pipeline definitions diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index c37ba313..ee0e0402 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -5,6 +5,7 @@ Manage Azure DevOps pipelines ### Available commands * [azdo pipelines agent](./azdo_pipelines_agent.md) +* [azdo pipelines delete](./azdo_pipelines_delete.md) * [azdo pipelines list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) * [azdo pipelines runs](./azdo_pipelines_runs.md) @@ -15,6 +16,13 @@ Manage Azure DevOps pipelines - `p` +### Examples + +```bash +# Delete a pipeline definition +azdo pipelines delete Fabrikam/42 --yes +``` + ### See also * [azdo](./azdo.md) diff --git a/docs/azdo_pipelines_delete.md b/docs/azdo_pipelines_delete.md new file mode 100644 index 00000000..ee24a965 --- /dev/null +++ b/docs/azdo_pipelines_delete.md @@ -0,0 +1,41 @@ +## Command `azdo pipelines delete` + +``` +azdo pipelines delete [ORGANIZATION/]PROJECT/PIPELINE [flags] +``` + +Delete a pipeline definition by ID or name. + +The command prompts for confirmation unless --yes is supplied. + + +### Options + + +* `-y`, `--yes` + + Skip the confirmation prompt. + + +### ALIASES + +- `d` +- `del` +- `rm` + +### Examples + +```bash +# Delete a pipeline by ID using the default organization +azdo pipelines delete Fabrikam/42 --yes + +# Delete a pipeline by name +azdo pipelines delete 'myorg/Fabrikam/My Pipeline' + +# Delete with confirmation +azdo pipelines delete Fabrikam/MyPipeline +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md)