Skip to content

feat: Implement azdo boards work-item delete command #269

@tmeckel

Description

@tmeckel

Sub-issue of #138. Sibling of #136 (work-item list), #203 (work-item create), #238 (work-item show).

Command Description

Delete a single Azure Boards work item by ID. Without --destroy, the work item is moved to the Recycle Bin and can be restored via the Azure DevOps web UI. With --destroy, the work item is permanently removed and cannot be recovered. Mirrors az boards work-item delete.

The REST surface is Work Items - Delete (REST 7.1):

DELETE https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/{id}?destroy={bool}&api-version=7.1

Response is a WorkItemDelete object (id, code, deletedBy, deletedDate, message, name, project, type, url, resource).

Locked Decisions

# Decision Rationale
1 Org-scoped target. Use: "delete [ORGANIZATION/]ID". The work item ID is unique within the organization; a project segment is not required. Matches az boards work-item delete (no project in positional) and internal/cmd/boards/workitem/show (org-scoped).
2 Aliases: []string{"d", "del", "rm"}. Per AGENTS.md destructive command convention.
3 cobra.ExactArgs(1). One positional target: the work item ID.
4 Parse the single positional with util.ParseTargetWithDefaultOrganization(ctx, args[0]). scope.Targets[0] is the ID string. Existing helper handles 1- and 2-segment forms.
5 Project is an optional --project flag, not part of the positional. Default empty. When set, passed to DeleteWorkItemArgs.Project; when empty, the SDK receives nil and the REST URL omits the /project/ segment. Matches the Python's delete_work_item which always passes project (possibly empty). Keeps the positional ergonomic (delete 1234 --yes).
6 Destructive: requires confirmation unless --yes is provided. Prompt text uses the same wording as the Python: "Are you sure you want to delete this work item?". When --destroy is set, the prompt text changes to "Are you sure you want to permanently destroy this work item? This cannot be undone.". Mirrors the AzDO Extension's delete_work_item confirmation. On cancellation, return util.ErrCancel.
7 --destroy is an optional bool flag (default false). When set, DeleteWorkItemArgs.Destroy = &true and the REST query param is ?destroy=true. Matches Python --destroy. Permanent deletion is the explicit-opt-in escalation.
8 Plain output: print Deleted work item <ID> to stdout. When --destroy is set, print Permanently deleted work item <ID>. Mirrors the Python's print('Deleted work item {}'.format(id)).
9 JSON output: pass the raw *workitemtracking.WorkItemDelete returned by DeleteWorkItem directly to opts.exporter.Write. No view struct. The SDK type is the API surface. Symmetric with #203 (create).
10 No table output. Delete returns a single status (the WorkItemDelete envelope), not a work item. The plain text message is the right human-readable surface; --json covers structured needs. Mirrors the show-command convention.
11 Uses vendored workitemtracking.Client.DeleteWorkItem. Vendor verified at vendor/.../v7/workitemtracking/client.go:865 (impl) and :61 (interface).
12 No new mock needed. internal/mocks/workitemtracking_client_mock.go:281-294 already mocks DeleteWorkItem.
13 No pre-check that the work item exists. The REST 404 is surfaced via util.FlagErrorWrap. Saves a round-trip. REST error is already clear.
14 ID must parse as a positive integer. If strconv.Atoi(scope.Targets[0]) fails or returns <= 0, return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0]) and do not call the SDK. Defends against typo'd IDs without a wasted round-trip.
15 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.

Command Signature

var deleteCmd = &cobra.Command{
    Use:     "delete [ORGANIZATION/]ID",
    Aliases: []string{"d", "del", "rm"},
    Short:   "Delete a work item.",
    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.
    `),
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runDelete(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Type Default Required Description
--yes bool false no Skip the confirmation prompt.
--destroy bool false no Permanently delete the work item (bypasses Recycle Bin).
--project string "" no Project name or ID. Routes the REST call through /project/_apis/wit/workitems/{id}. Omit to use the org-level URL.
--organization string $AZDO_ORGANIZATION or default config no Azure DevOps organization. Omit to use the default.

Also add util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "code", "deletedBy", "deletedDate", "message", "name", "project", "type", "url", "resource"}) (every JSON-tagged field of *workitemtracking.WorkItemDelete).

ID Validation

After positional parsing, before the confirmation prompt:

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])
}

Confirmation Prompt

When --yes is not set:

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()
}

JSON Output Contract

Pass the raw *workitemtracking.WorkItemDelete returned by DeleteWorkItem to opts.exporter.Write. No view struct.

JSON fields exposed (matching SDK struct tags): id, code, deletedBy, deletedDate, message, name, project, type, url, resource.

Table Output Contract

N/A. On success, print one line to stdout:

Deleted work item <ID>

When --destroy is set:

Permanently deleted work item <ID>

Command Wiring

  1. Create internal/cmd/boards/workitem/delete/delete.go with package delete, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/delete" to internal/cmd/boards/workitem/workitem.go.
  3. Register in workitem.go with cmd.AddCommand(delete.NewCmd(ctx)) and extend the group's Example block.

API Surface

Vendored SDK call (from vendor/.../v7/workitemtracking/client.go:865):

args := workitemtracking.DeleteWorkItemArgs{
    Id:      &id, // int parsed from scope.Targets[0]
    Destroy: &opts.destroy,
}
if opts.project != "" {
    args.Project = &opts.project
}
res, err := wit.DeleteWorkItem(ctx, args)
if err != nil {
    return fmt.Errorf("failed to delete work item %d: %w", id, err)
}

The vendored SDK enforces args.Id != nil — will return ArgumentNilError if missing. No extra validation needed in our wrapper beyond the positive-integer check (Decision 14).

Reference Existing Patterns

  • internal/cmd/pipelines/variablegroup/delete/delete.go:30-170primary reference. Mirrors the Use/Aliases, the confirmation prompt lifecycle, the ios.StopProgressIndicator/ios.StartProgressIndicator dance, the util.ErrCancel return path, and the JSON output split.
  • internal/cmd/pipelines/folder/delete/delete.go (issue feat: Implement azdo pipelines folder delete command #264) — sibling for the simpler destructive shape (no project-references or --all).
  • internal/cmd/boards/workitem/show — sibling for the org-scoped ParseTargetWithDefaultOrganization usage and the --project flag pattern.
  • internal/cmd/boards/workitem/create (feat: Implement azdo boards work-item create command #203) — sibling for the JSON Patch output conventions (Decision 9: raw SDK type to opts.exporter.Write).

TDD Test Plan

Test name Scenario Expected
TestNewCmd_delete Inspect the command struct. Use == "delete [ORGANIZATION/]ID", Aliases == ["d", "del", "rm"], Args == cobra.ExactArgs(1).
Test_runDelete_success_withYes --yes, mock DeleteWorkItem returns &workitemtracking.WorkItemDelete{Id: ptr(1234), DeletedDate: ptr("2026-06-05T...")}. Returns nil error; prints Deleted work item 1234 to stdout.
Test_runDelete_success_destroy --yes --destroy, mock returns same. Prints Permanently deleted work item 1234. args.Destroy == &true is passed to the SDK.
Test_runDelete_success_withProject --project=Fabrikam --yes, mock returns same. args.Project == ptr("Fabrikam") is passed to the SDK.
Test_runDelete_success_noProject --yes, no --project. args.Project == nil is passed to the SDK.
Test_runDelete_success_confirmed Prompter stubbed to return true. Returns nil error; deletes; prints Deleted work item 1234.
Test_runDelete_cancelled Prompter stubbed to return false. Returns util.ErrCancel. The SDK is not called.
Test_runDelete_notInteractive ios.CanPrompt() == false, no --yes. Returns util.FlagErrorf("--yes required when not running interactively"). SDK is not called.
Test_runDelete_invalidID Positional is "abc". Returns util.FlagErrorf with "work item ID must be a positive integer; got \"abc\"". SDK is not called.
Test_runDelete_zeroID Positional is "0". Returns util.FlagErrorf with the same message. SDK is not called.
Test_runDelete_negativeID Positional is "-5". Returns util.FlagErrorf. SDK is not called.
Test_runDelete_orgScopeOnly Positional is "1234" (no org). Mocked cmdCtx.Config() returns a default org. args.Id == ptr(1234), scope.Organization is the default.
Test_runDelete_explicitOrg Positional is "myorg/1234". args.Id == ptr(1234), scope.Organization is "myorg".
Test_runDelete_APIError Mock returns errors.New("boom"). --yes. Returns wrapped error including the ID.
Test_runDelete_success_JSON --json flag set, mock returns *WorkItemDelete. Exporter receives the raw *WorkItemDelete (assert res.Id == ptr(1234)).
Test_runDelete_destroyPromptText --destroy, prompter stubbed to return true. Capture the prompt message. Message contains "permanently destroy" and "cannot be undone".
Test_runDelete_missingID User runs azdo boards work-item delete with no args. Cobra args validation returns the standard "accepts 1 arg(s)" error.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions