From 3d25a82b2dd3b2808147107c0b3e47e86d284ecd Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 20:20:55 +0000 Subject: [PATCH 1/6] chore(deps): bump typos from v1.47.2 to v1.49.0 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 405b315b..efcea228 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,6 @@ repos: - id: actionlint-system - repo: https://github.com/crate-ci/typos - rev: v1.47.2 + rev: v1.49.0 hooks: - id: typos From ac7170c4805033e016c704f447d938c1142b663e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 20:22:08 +0000 Subject: [PATCH 2/6] docs(agents): remove npx prefix from skills commands --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a01d811..a4a33d60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,15 +23,15 @@ This project relies on the [samber/cc-skills-golang](https://github.com/samber/c ```bash # Inspect which Go skills are currently installed -npx skills list | grep golang- +skills list | grep golang- # Install a single skill -npx skills add samber/cc-skills-golang --skill golang-code-style -y +skills add samber/cc-skills-golang --skill golang-code-style -y ``` **Workflow:** -1. Before writing or modifying Go code, run `npx skills list | grep golang-` to see which skills are installed in this project or globally. The agent loads them automatically based on description matching; if `golang-how-to` is installed it also force-loads relevant secondary skills (e.g. Cobra review → `golang-spf13-cobra` + `golang-cli` + `golang-error-handling`). +1. Before writing or modifying Go code, run `skills list | grep golang-` to see which skills are installed in this project or globally. The agent loads them automatically based on description matching; if `golang-how-to` is installed it also force-loads relevant secondary skills (e.g. Cobra review → `golang-spf13-cobra` + `golang-cli` + `golang-error-handling`). 2. Treat the installed samber skills as the source of truth for general Go rules (style, naming, error wrapping, nil safety, testing patterns, concurrency, context propagation, etc.). 3. Apply the project-specific rules in the sections below only where they **deviate** from samber. Every such section declares `> Supersedes samber/cc-skills-golang@ for this project.` at the top — samber's ⚙️ override mechanism is honored automatically. 4. If a section below does not declare a supersession, the samber skills win on that topic. From 6e30f22adc3344c695e13147f461893e3b3123b8 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 20:22:59 +0000 Subject: [PATCH 3/6] feat(workitem): add function to verify work item project ownership --- internal/cmd/boards/workitem/shared/shared.go | 21 +++++++++++ .../cmd/boards/workitem/shared/shared_test.go | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 internal/cmd/boards/workitem/shared/shared.go create mode 100644 internal/cmd/boards/workitem/shared/shared_test.go diff --git a/internal/cmd/boards/workitem/shared/shared.go b/internal/cmd/boards/workitem/shared/shared.go new file mode 100644 index 00000000..7f5d4ffe --- /dev/null +++ b/internal/cmd/boards/workitem/shared/shared.go @@ -0,0 +1,21 @@ +package shared + +import ( + "fmt" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" +) + +// TeamProjectField is the work item field that identifies the parent project. +const TeamProjectField = "System.TeamProject" + +// BelongsToProject reports whether the work item's System.TeamProject field +// matches the given project. Used to verify project ownership before +// mutating or deleting work items. +func BelongsToProject(item *workitemtracking.WorkItem, project string) bool { + if item == nil || item.Fields == nil { + return false + } + got, ok := (*item.Fields)[TeamProjectField] + return ok && fmt.Sprint(got) == project +} diff --git a/internal/cmd/boards/workitem/shared/shared_test.go b/internal/cmd/boards/workitem/shared/shared_test.go new file mode 100644 index 00000000..56061291 --- /dev/null +++ b/internal/cmd/boards/workitem/shared/shared_test.go @@ -0,0 +1,35 @@ +package shared + +import ( + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBelongsToProject(t *testing.T) { + t.Parallel() + + fields := map[string]interface{}{TeamProjectField: "Fabrikam"} + tests := []struct { + name string + item *workitemtracking.WorkItem + project string + want bool + }{ + {name: "nil item", item: nil, project: "Fabrikam", want: false}, + {name: "nil fields", item: &workitemtracking.WorkItem{}, project: "Fabrikam", want: false}, + {name: "missing field", item: &workitemtracking.WorkItem{Fields: &map[string]interface{}{}}, project: "Fabrikam", want: false}, + {name: "match", item: &workitemtracking.WorkItem{Fields: &fields}, project: "Fabrikam", want: true}, + {name: "mismatch", item: &workitemtracking.WorkItem{Fields: &fields}, project: "Contoso", want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, BelongsToProject(tc.item, tc.project)) + }) + } + + require.Equal(t, "System.TeamProject", TeamProjectField) +} From b14ad498a7325280fd70146ae13f03cc2aa85a52 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 20:23:18 +0000 Subject: [PATCH 4/6] feat: Implement `azdo boards work-item delete` command Fixes #269 --- internal/cmd/boards/workitem/delete/delete.go | 153 ++++++++++++++++++ internal/cmd/boards/workitem/workitem.go | 5 + 2 files changed, 158 insertions(+) create mode 100644 internal/cmd/boards/workitem/delete/delete.go diff --git a/internal/cmd/boards/workitem/delete/delete.go b/internal/cmd/boards/workitem/delete/delete.go new file mode 100644 index 00000000..de67d9b7 --- /dev/null +++ b/internal/cmd/boards/workitem/delete/delete.go @@ -0,0 +1,153 @@ +package delete + +import ( + "fmt" + "strconv" + + "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/workitem/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type opts struct { + targetArg string + yes bool + destroy bool + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "delete [ORG:]PROJECT/ID", + Short: "Delete a work item.", + Aliases: []string{"d", "del", "rm"}, + Long: heredoc.Doc(` + Delete a work item by ID. By default the work item is moved to the + Recycle Bin and can be restored via the Azure DevOps web UI. + Use --destroy to permanently remove the work item; this cannot be + undone. + `), + Example: heredoc.Doc(` + # Delete a work item in the default organization + azdo boards work-item delete Fabrikam/42 --yes + + # Permanently destroy a work item in a specific organization + azdo boards work-item delete myorg:Fabrikam/42 --destroy --yes + `), + Args: util.ExactArgs(1, "project/work item target 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.") + cmd.Flags().BoolVar(&opts.destroy, "destroy", false, "Permanently delete the work item (bypasses Recycle Bin).") + util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "code", "deletedBy", "deletedDate", "message", "name", "project", "type", "url", "resource"}) + + 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.ParseProjectTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + id, err := strconv.Atoi(scope.Targets[0]) + if err != nil || id <= 0 { + return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0]) + } + + zap.L().Debug( + "resolved work item delete target", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("workItemId", id), + ) + + client, err := cmdCtx.ClientFactory().WorkItemTracking(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create work item tracking client: %w", err) + } + + item, err := client.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{ + Id: &id, + Project: types.ToPtr(scope.Project), + Fields: types.ToPtr([]string{shared.TeamProjectField}), + }) + if err != nil { + return fmt.Errorf("failed to fetch work item %d: %w", id, err) + } + if !shared.BelongsToProject(item, scope.Project) { + return fmt.Errorf("work item %d does not belong to project %q", id, scope.Project) + } + + 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 + } + message := "Are you sure you want to delete this work item?" + if opts.destroy { + message = "Are you sure you want to permanently destroy this work item? This cannot be undone." + } + confirmed, err := prompter.Confirm(message, false) + if err != nil { + return err + } + if !confirmed { + zap.L().Debug("work item deletion canceled by user", zap.Int("workItemId", id)) + return util.ErrCancel + } + ios.StartProgressIndicator() + } + + res, err := client.DeleteWorkItem(cmdCtx.Context(), workitemtracking.DeleteWorkItemArgs{ + Project: types.ToPtr(scope.Project), + Id: &id, + Destroy: &opts.destroy, + }) + if err != nil { + return fmt.Errorf("failed to delete work item %d: %w", id, err) + } + + zap.L().Debug( + "work item deleted", + zap.Int("workItemId", id), + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Bool("destroy", opts.destroy), + ) + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, res) + } + + if opts.destroy { + fmt.Fprintf(ios.Out, "Permanently deleted work item %d\n", id) + return nil + } + fmt.Fprintf(ios.Out, "Deleted work item %d\n", id) + return nil +} diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go index d6c949df..58eb82e9 100644 --- a/internal/cmd/boards/workitem/workitem.go +++ b/internal/cmd/boards/workitem/workitem.go @@ -3,6 +3,7 @@ package workitem import ( "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/delete" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/list" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -15,10 +16,14 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Example: heredoc.Doc(` # List work items in a project azdo boards work-item list Fabrikam + + # Delete a work item + azdo boards work-item delete Fabrikam/42 --yes `), } cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(delete.NewCmd(ctx)) return cmd } From d4241dc93e34c4ebf446139bff0e691b60e47800 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 20:24:02 +0000 Subject: [PATCH 5/6] test: add tests for boards work-item delete command --- .../cmd/boards/workitem/delete/delete_test.go | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 internal/cmd/boards/workitem/delete/delete_test.go diff --git a/internal/cmd/boards/workitem/delete/delete_test.go b/internal/cmd/boards/workitem/delete/delete_test.go new file mode 100644 index 00000000..45691545 --- /dev/null +++ b/internal/cmd/boards/workitem/delete/delete_test.go @@ -0,0 +1,394 @@ +package delete + +import ( + "bytes" + "context" + "errors" + "fmt" + "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 dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + 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), + 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.cmd.EXPECT().Prompter().Return(deps.prompter, nil).AnyTimes() + if organization != "" { + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, 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 (d *dependencies) stubPreflight(t *testing.T, project string) { + d.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemArgs) (*workitemtracking.WorkItem, error) { + require.NotNil(t, args.Id) + require.NotNil(t, args.Project) + assert.Equal(t, project, *args.Project) + fields := map[string]interface{}{"System.TeamProject": project} + return &workitemtracking.WorkItem{Id: args.Id, Fields: &fields}, nil + }, + ) +} + +func TestNewCmd_delete(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "delete [ORG:]PROJECT/ID", cmd.Use) + assert.ElementsMatch(t, []string{"d", "del", "rm"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) + require.NoError(t, cmd.Args(cmd, []string{"Fabrikam/1234"})) + assert.Error(t, cmd.Args(cmd, []string{"Fabrikam/1234", "Extra"})) + assert.Error(t, cmd.Args(cmd, []string{})) + + f := cmd.Flags() + assert.NotNil(t, f.Lookup("yes")) + assert.NotNil(t, f.Lookup("destroy")) + assert.NotNil(t, f.Lookup("json")) +} + +func TestNewCmd_missingTarget(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "project/work item target required") +} + +func TestRunDelete_success_withYes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + defaultOrg string + expectedOrg string + }{ + { + name: "implicit org", + targetArg: "Fabrikam/1234", + defaultOrg: "myorg", + expectedOrg: "myorg", + }, + { + name: "explicit org", + targetArg: "myorg:Fabrikam/1234", + expectedOrg: "myorg", + }, + } + + 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.stubPreflight(t, "Fabrikam") + + deps.wit.EXPECT().DeleteWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteWorkItemArgs) (*workitemtracking.WorkItemDelete, error) { + require.NotNil(t, args.Project) + assert.Equal(t, "Fabrikam", *args.Project) + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) + require.NotNil(t, args.Destroy) + assert.False(t, *args.Destroy) + return &workitemtracking.WorkItemDelete{Id: args.Id}, nil + }, + ) + + err := runDelete(deps.cmd, &opts{targetArg: tc.targetArg, yes: true}) + require.NoError(t, err) + assert.Equal(t, "Deleted work item 1234\n", deps.stdout.String()) + }) + } +} + +func TestRunDelete_success_destroy(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + deps.wit.EXPECT().DeleteWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteWorkItemArgs) (*workitemtracking.WorkItemDelete, error) { + require.NotNil(t, args.Destroy) + assert.True(t, *args.Destroy) + return &workitemtracking.WorkItemDelete{Id: args.Id}, nil + }, + ) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true, destroy: true}) + require.NoError(t, err) + assert.Equal(t, "Permanently deleted work item 1234\n", deps.stdout.String()) +} + +func TestRunDelete_success_confirmed(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().DeleteWorkItem(gomock.Any(), gomock.Any()).Return(&workitemtracking.WorkItemDelete{}, nil) + deps.prompter.EXPECT().Confirm( + "Are you sure you want to delete this work item?", + false, + ).Return(true, nil) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234"}) + require.NoError(t, err) + assert.Equal(t, "Deleted work item 1234\n", deps.stdout.String()) +} + +func TestRunDelete_cancelled(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.prompter.EXPECT().Confirm(gomock.Any(), false).Return(false, nil) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234"}) + require.ErrorIs(t, err, util.ErrCancel) + assert.Empty(t, deps.stdout.String()) +} + +func TestRunDelete_notInteractive(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--yes required when not running interactively") +} + +func TestRunDelete_invalidID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target string + expected string + }{ + {name: "non numeric", target: "Fabrikam/abc", expected: `work item ID must be a positive integer; got "abc"`}, + {name: "zero", target: "Fabrikam/0", expected: `work item ID must be a positive integer; got "0"`}, + {name: "negative", target: "Fabrikam/-5", expected: `work item ID must be a positive integer; got "-5"`}, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + + err := runDelete(deps.cmd, &opts{targetArg: tc.target, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expected) + }) + } +} + +func TestRunDelete_ProjectMismatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fields *map[string]interface{} + expected string + }{ + { + name: "different project", + fields: &map[string]interface{}{"System.TeamProject": "OtherProject"}, + expected: `work item 1234 does not belong to project "Fabrikam"`, + }, + { + name: "missing team project field", + fields: &map[string]interface{}{}, + expected: `work item 1234 does not belong to project "Fabrikam"`, + }, + { + name: "nil fields", + expected: `work item 1234 does not belong to project "Fabrikam"`, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).Return( + &workitemtracking.WorkItem{Fields: tc.fields}, nil, + ) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expected) + assert.Empty(t, deps.stdout.String()) + }) + } +} + +func TestRunDelete_preflightError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch work item 1234: boom") +} + +func TestRunDelete_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().DeleteWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete work item 1234: boom") +} + +func TestRunDelete_clientError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "", false) + deps.setupDefaultOrg("myorg") + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), "myorg").Return(nil, fmt.Errorf("no client")) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create work item tracking client: no client") +} + +func TestRunDelete_missingDefaultOrganization(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "", false) + deps.setupDefaultOrg("") + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no organization specified") +} + +func TestRunDelete_success_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().DeleteWorkItem(gomock.Any(), gomock.Any()).Return( + &workitemtracking.WorkItemDelete{Id: types.ToPtr(1234), Name: types.ToPtr("Fix bug")}, nil, + ) + + exporter := &captureExporter{} + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", yes: true, exporter: exporter}) + require.NoError(t, err) + + got, ok := exporter.data.(*workitemtracking.WorkItemDelete) + require.True(t, ok, "exporter must receive the raw WorkItemDelete") + assert.Equal(t, 1234, *got.Id) + assert.Equal(t, "Fix bug", *got.Name) +} + +func TestRunDelete_destroyPromptText(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().DeleteWorkItem(gomock.Any(), gomock.Any()).Return(&workitemtracking.WorkItemDelete{}, nil) + + var promptMessage string + deps.prompter.EXPECT().Confirm(gomock.Any(), false).DoAndReturn( + func(message string, defaultValue bool) (bool, error) { + promptMessage = message + return true, nil + }, + ) + + err := runDelete(deps.cmd, &opts{targetArg: "Fabrikam/1234", destroy: true}) + require.NoError(t, err) + assert.Contains(t, promptMessage, "permanently destroy") + assert.Contains(t, promptMessage, "cannot be undone") +} + +type captureExporter struct { + data any +} + +func (c *captureExporter) Fields() []string { return nil } +func (c *captureExporter) Write(_ *iostreams.IOStreams, data any) error { + c.data = data + return nil +} From 8d6e1371741c127a5746851d2c3f769a8290f35b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 20:24:48 +0000 Subject: [PATCH 6/6] docs: add documentation for azdo boards work-item delete command --- docs/azdo_boards_work-item.md | 4 ++ docs/azdo_boards_work-item_delete.md | 59 ++++++++++++++++++++++++++++ docs/azdo_help_reference.md | 18 +++++++++ 3 files changed, 81 insertions(+) create mode 100644 docs/azdo_boards_work-item_delete.md diff --git a/docs/azdo_boards_work-item.md b/docs/azdo_boards_work-item.md index 72d7f2da..f2d7d473 100644 --- a/docs/azdo_boards_work-item.md +++ b/docs/azdo_boards_work-item.md @@ -4,6 +4,7 @@ Work with Azure Boards work items. ### Available commands +* [azdo boards work-item delete](./azdo_boards_work-item_delete.md) * [azdo boards work-item list](./azdo_boards_work-item_list.md) ### Examples @@ -11,6 +12,9 @@ Work with Azure Boards work items. ```bash # List work items in a project azdo boards work-item list Fabrikam + +# Delete a work item +azdo boards work-item delete Fabrikam/42 --yes ``` ### See also diff --git a/docs/azdo_boards_work-item_delete.md b/docs/azdo_boards_work-item_delete.md new file mode 100644 index 00000000..ee380b78 --- /dev/null +++ b/docs/azdo_boards_work-item_delete.md @@ -0,0 +1,59 @@ +## Command `azdo boards work-item delete` + +``` +azdo boards work-item delete [ORG:]PROJECT/ID [flags] +``` + +Delete a work item by ID. By default the work item is moved to the +Recycle Bin and can be restored via the Azure DevOps web UI. +Use --destroy to permanently remove the work item; this cannot be +undone. + + +### Options + + +* `--destroy` + + Permanently delete the work item (bypasses Recycle Bin). + +* `-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. + +* `-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 + +`code`, `deletedBy`, `deletedDate`, `id`, `message`, `name`, `project`, `resource`, `type`, `url` + +### Examples + +```bash +# Delete a work item in the default organization +azdo boards work-item delete Fabrikam/42 --yes + +# Permanently destroy a work item in a specific organization +azdo boards work-item delete myorg:Fabrikam/42 --destroy --yes +``` + +### See also + +* [azdo boards work-item](./azdo_boards_work-item.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 6309af40..820a430c 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -226,6 +226,24 @@ u, up Work with Azure Boards work items. +#### `azdo boards work-item delete [ORG:]PROJECT/ID [flags]` + +Delete a work item. + +``` + --destroy Permanently delete the work item (bypasses Recycle Bin). +-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. +-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 work-item list [ORG:]PROJECT [flags]` List work items belonging to a project.