Skip to content

feat: Implement azdo boards area project delete command #206

Description

@tmeckel

Sub-issue of #141. Sibling of #204 (area project create) and #143 (area project list). Hardened spec — do not re-derive decisions.

Command Description

Delete an area path (classification node) from a project's area tree. The endpoint is the Classification Nodes REST 7.1 DELETE:

DELETE https://dev.azure.com/{organization}/{project}/_apis/wit/classificationnodes/Areas/{nodePath}?api-version=7.1

The optional ?$reclassifyId={id} query param moves work items from the deleted node to the specified target node before deletion. The SDK returns no body (204 No Content).

For v1 the structureGroup is hardcoded to Areas (lowercase literal "areas"). Iteration gets its own command (#207).

Locked Decisions (do not re-derive)

# Decision Rationale
1 Use the vendored SDK workitemtracking.Client.DeleteClassificationNode (not raw HTTP). Mock is already generated. Consistent with #204; raw HTTP is the outlier (see list.go).
2 --path is the only required flag. It specifies the path of the node to delete. The path is the node's own path (not parent). <Project>/<Scope> segments are stripped via BuildClassificationPath. Empty/whitespace rejected with util.FlagErrorf. The SDK's DeleteClassificationNodeArgs.Path is the only delete-target mechanism; the SDK exposes no NodeId route.
3 Optional --reclassify-id . Maps to DeleteClassificationNodeArgs.ReclassifyId. When set, work items are moved to that node before deletion. Mirrors the REST $reclassifyId query param.
4 No --reclassify-path flag in v1. SDK only supports ReclassifyId (int), not a path. SDK constraint.
5 No --id flag. The SDK's DeleteClassificationNodeArgs has no NodeId field; only path-based deletion is supported. SDK constraint.
6 No --force flag. Use --yes to skip confirmation (existing pattern in pipelines/variablegroup/delete, serviceendpoint/delete, security/permission/delete). Standard destructive convention.
7 --yes, -y via BoolVarP. Non-TTY + no --yes returns util.FlagErrorf("--yes required when not running interactively") — mirrors internal/cmd/pipelines/variablegroup/delete/delete.go:125. Match existing pattern.
8 Confirmation prompt via prompter.Confirm(...). User cancel returns util.ErrCancel. Stop the progress indicator before the prompt and restart after — mirrors internal/cmd/pipelines/variablegroup/delete/delete.go:127,141. Match existing pattern.
9 Aliases: d, del, rm per AGENTS.md destructive convention. Standard.
10 Path normalization via shared.BuildClassificationPath(scope.Project, true, "Area", opts.path). URL-escapes segments; returns the path portion. One helper, one source of truth.
11 StructureGroup hardcoded to &workitemtracking.TreeStructureGroupValues.Areas (literal "areas"). No --structure-group flag. Scope discipline; iteration gets its own command (#207).
12 JSON output: deleteResult{Deleted bool, Path string, ReclassifyID *int} via util.AddJSONFlags. ReclassifyID is *int with omitempty so it only appears when the flag was set. Mirrors internal/cmd/pipelines/variablegroup/delete/delete.go:25-28.
13 Default output: single line Deleted area path: <path> via fmt.Fprintf(ios.Out, ...). When --reclassify-id is set, append \nReclassified work items to: <id>. Matches internal/cmd/pipelines/variablegroup/delete/delete.go:168 simplicity.
14 Debug log: organization, project, path, reclassifyId (if set), plus a canceled event when user declines. Match create pattern.
15 No new SDK client, no new helper, no new package. Only the new delete package + 3 LOC in project.go to wire it. Mandate: minimal code.
16 Mock for DeleteClassificationNode is already generated at internal/mocks/workitemtracking_client_mock.go:196-207. Do not regenerate. Verified.

Command Signature

azdo boards area project delete [ORGANIZATION/]PROJECT --path 
  • Aliases: d, del, rm
  • Positional parsing via util.ParseProjectScope(ctx, scopeArg) (defined in internal/cmd/util/scope.go:78-108); errors wrapped with util.FlagErrorWrap.

Flags (mapped to SDK/REST)

Flag Maps to Notes
--path (required) DeleteClassificationNodeArgs.Path (URL segment, node's own path) URL-escaped; <Project>/<Area> segments stripped
--reclassify-id (optional) DeleteClassificationNodeArgs.ReclassifyId (int, REST $reclassifyId query param) Work items reclassified before deletion
--yes, -y (optional) (no SDK mapping) Skip confirmation prompt; required when non-TTY

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter, []string{
    "deleted", "path", "reclassifyId",
})

View struct (only used in JSON path; the SDK returns only error):

type deleteResult struct {
    Deleted      bool   `json:"deleted"`
    Path         string `json:"path"`
    ReclassifyID *int   `json:"reclassifyId,omitempty"`
}

Default output: single line Deleted area path: <path> (Decision 13). When --reclassify-id is set, append \nReclassified work items to: <id>.

Command Wiring

  • Package path: internal/cmd/boards/area/project/delete
  • Files:
    • delete.goNewCmd(ctx util.CmdContext) *cobra.Command + deleteOptions + runDelete
    • delete_test.go — table-driven gomock tests
  • Update internal/cmd/boards/area/project/project.go:
    import (
        deletecmd "github.com/tmeckel/azdo-cli/internal/cmd/boards/area/project/delete"
    )
    cmd.AddCommand(deletecmd.NewCmd(ctx))
  • Existing higher-level parents must already remain wired: boardsareaprojectdelete.

Code Skeleton (canonical, copy verbatim)

§1. deleteOptions struct

type deleteOptions struct {
    scopeArg     string
    path         string // --path (node's own path)
    reclassifyID int    // --reclassify-id
    yes          bool   // --yes, -y
    exporter     util.Exporter
}

§2. NewCmd shape (no surprises)

func NewCmd(ctx util.CmdContext) *cobra.Command {
    opts := &deleteOptions{}

    cmd := &cobra.Command{
        Use:   "delete [ORGANIZATION/]PROJECT --path ",
        Short: "Delete an area path from a project.",
        Long: heredoc.Doc(`
            Delete an area path (classification node) 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 area path
            azdo boards area project delete Fabrikam --path Payments --yes

            # Delete a nested area path with a confirmation prompt
            azdo boards area project delete Fabrikam --path Payments/Refunds

            # Reclassify work items to node 42 before deletion
            azdo boards area project delete Fabrikam --path Payments/Refunds \
                --reclassify-id 42 --yes

            # Emit JSON
            azdo boards area project delete Fabrikam --path Payments --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 area node to delete (under /Area, leading /Area stripped).")
    cmd.Flags().IntVar(&opts.reclassifyID, "reclassify-id", 0, "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
}

§3. runDelete skeleton

type deleteResult struct {
    Deleted      bool   `json:"deleted"`
    Path         string `json:"path"`
    ReclassifyID *int   `json:"reclassifyId,omitempty"`
}

func runDelete(ctx util.CmdContext, opts *deleteOptions) error {
    ios, err := ctx.IOStreams()
    if err != nil {
        return err
    }

    ios.StartProgressIndicator()
    defer ios.StopProgressIndicator()

    scope, err := util.ParseProjectScope(ctx, opts.scopeArg)
    if err != nil {
        return util.FlagErrorWrap(err)
    }

    nodePath, err := shared.BuildClassificationPath(scope.Project, true, "Area", opts.path)
    if err != nil {
        return util.FlagErrorf("invalid --path: %w", err)
    }
    if nodePath == "" {
        return util.FlagErrorf("--path must reference a child of /Area, not the area root")
    }

    // Confirmation
    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 area path %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("area path 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 area path",
        zap.String("organization", scope.Organization),
        zap.String("project", scope.Project),
        zap.String("path", nodePath),
        zap.Int("reclassifyId", opts.reclassifyID),
    )

    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.Areas),
        Path:           types.ToPtr(nodePath),
    }
    if opts.reclassifyID != 0 {
        args.ReclassifyId = types.ToPtr(opts.reclassifyID)
    }

    if err := wit.DeleteClassificationNode(ctx.Context(), args); err != nil {
        return fmt.Errorf("failed to delete area path: %w", err)
    }

    ios.StopProgressIndicator()

    if opts.exporter != nil {
        result := deleteResult{
            Deleted: true,
            Path:    nodePath,
        }
        if opts.reclassifyID != 0 {
            result.ReclassifyID = types.ToPtr(opts.reclassifyID)
        }
        return opts.exporter.Write(ios, result)
    }

    fmt.Fprintf(ios.Out, "Deleted area path: %s\n", nodePath)
    if opts.reclassifyID != 0 {
        fmt.Fprintf(ios.Out, "Reclassified work items to: %d\n", opts.reclassifyID)
    }
    return nil
}

§4. Test fixture (copy from workitem/list/list_test.go:765-844; add prompter mock from security/permission/delete)

type fakeDeleteDeps struct {
    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.SetStdoutTTY(canPrompt)
    io.SetStderrTTY(canPrompt)
    io.SetStdinTTY(canPrompt)
    io.SetCanPrompt(canPrompt)

    deps := &fakeDeleteDeps{
        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
}

(The fixture takes a canPrompt bool so individual tests can flip TTY state — required for the confirmation tests.)

API Surface

Reuse the already-vendored client. No new SDK clients required.

Mock for DeleteClassificationNode is already generated at internal/mocks/workitemtracking_client_mock.go:196-207. No mock regeneration needed.

Implementation Approach (TDD, reuse-first, minimal)

Use the golang-spf13-cobra, golang-cli, golang-testing, and golang-stretchr-testify skills as the source of truth for structure, flag wiring, args validators, table-driven tests, and mock verification.

Phase 1 — RED (tests first). Mirror setupFakeDeps from internal/cmd/boards/workitem/list/list_test.go:765-844 and the prompter mock wiring from internal/cmd/security/permission/delete/delete_test.go. Add delete_test.go with the following table-driven / behaviour tests, all using t.Parallel() and gomock (require for preconditions, assert for verifications):

  • TestNewCmd_RegistersAsDeleteLeaf — asserts cmd.Name() == "delete", cmd.Aliases contains d, del, rm, cmd.Use starts with delete [ORGANIZATION/]PROJECT.
  • TestNewCmd_PathFlagRequired — runs cmd.SetArgs([]string{"Fabrikam"}) + cmd.Execute(); asserts cobra MarkFlagRequired error mentioning path.
  • TestRunDelete_EmptyPathFlag — sets --path " "; asserts util.FlagErrorf returned with --path must not be empty.
  • TestRunDelete_RootNode_Rejected — sets --path "" and --path "Fabrikam/Area" (root only); asserts util.FlagErrorf because nodePath ends up empty.
  • TestRunDelete_DefaultScope — sets --path Payments --yes (non-TTY); asserts captured args.Path == "Payments", *args.StructureGroup == TreeStructureGroupValues.Areas (literal "areas"), *args.Project == "Fabrikam", args.ReclassifyId == nil.
  • TestRunDelete_NestedPath — sets --path "Payments/Sub" --yes; asserts args.Path == "Payments/Sub".
  • TestRunDelete_PathNormalizationStripsProjectAndArea — sets --path "Fabrikam/Area/Payments/Sub" --yes; asserts args.Path == "Payments/Sub".
  • TestRunDelete_PathURLEscaping — sets --path "My Team/Sub Team" --yes; asserts args.Path == "My%20Team/Sub%20Team".
  • TestRunDelete_StructureGroupIsAreas — asserts *args.StructureGroup == workitemtracking.TreeStructureGroupValues.Areas (literal "areas").
  • TestRunDelete_ReclassifyId_DefaultNil — no --reclassify-id; asserts args.ReclassifyId == nil.
  • TestRunDelete_ReclassifyId_Set — sets --reclassify-id 42 --yes; asserts *args.ReclassifyId == 42.
  • TestRunDelete_ProjectScopeParsing — table-driven: [ORG/]PROJECT, PROJECT, invalid ("org/proj/extra"), empty.
  • TestRunDelete_InvalidProjectScope — asserts util.FlagErrorWrap returned.
  • TestRunDelete_ClientFactoryError — stubs factory to return error; asserts wrapped error.
  • TestRunDelete_SDKError — stubs SDK to return error; asserts wrapped error.
  • TestRunDelete_YesFlag_SkipsPrompt (TTY, --yes) — asserts prompter.Confirm is not called, SDK is called.
  • TestRunDelete_ConfirmationPrompt_Yes (TTY, no --yes, user confirms) — asserts prompter.Confirm called with deletion prompt; SDK is called.
  • TestRunDelete_ConfirmationPrompt_No (TTY, no --yes, user declines) — asserts prompter.Confirm returns false; SDK is not called; returns util.ErrCancel.
  • TestRunDelete_NonTTY_NoYes_ReturnsError (non-TTY, no --yes) — asserts util.FlagErrorf with --yes required; SDK is not called.
  • TestRunDelete_DefaultOutput — asserts stdout contains Deleted area path: Payments\n.
  • TestRunDelete_DefaultOutput_WithReclassify — asserts stdout contains Deleted area path: Payments\nReclassified work items to: 42\n.
  • TestRunDelete_JSONOutput — sets --json; asserts JSON contains {"deleted":true,"path":"Payments"} and no reclassifyId key.
  • TestRunDelete_JSONOutput_WithReclassify — sets --json --reclassify-id 42; asserts JSON contains {"deleted":true,"path":"Payments","reclassifyId":42}.
  • TestRunDelete_OrganizationFromConfigDefault — when scopeArg is PROJECT (no org), asserts clientFact.WorkItemTracking(ctx, defaultOrg) is called with the configured default.

Phase 2 — GREEN (minimal implementation). Strict reuse rules:

  • No new helpers. Inline everything in runDelete (already short).
  • Reuse util.ParseProjectScope, util.AddJSONFlags, util.FlagErrorf/FlagErrorWrap, util.ExactArgs, types.GetValue, types.ToPtr, util.ErrCancel, ios.StartProgressIndicator/StopProgressIndicator, iostreams.Test as-is.
  • Reuse shared.BuildClassificationPath(scope.Project, true, "Area", opts.path) for path normalization; the helper already URL-escapes segments.
  • SDK call only: wit.DeleteClassificationNode(ctx.Context(), args) with args.Project, args.StructureGroup = &workitemtracking.TreeStructureGroupValues.Areas, args.Path, and conditionally args.ReclassifyId.
  • Progress indicator: ios.StartProgressIndicator() + defer ios.StopProgressIndicator(); call ios.StopProgressIndicator() before the confirmation prompt and ios.StartProgressIndicator() after a confirmed prompt (mirrors internal/cmd/pipelines/variablegroup/delete/delete.go:127,141).
  • Confirmation prompt: gated on !opts.yes and !ios.CanPrompt(); on cancel, return util.ErrCancel.
  • Output split: JSON via opts.exporter.Write(ios, deleteResult{...}) (constructed in runDelete, NOT from the SDK return — the SDK returns only error); default via fmt.Fprintf(ios.Out, ...).
  • Debug log at the point of the SDK call: organization, project, path, reclassifyId; plus a canceled event when the user declines.

Target delta: delete.go ≤ ~140 LOC, delete_test.go ≤ ~500 LOC (22 tests), parent project.go +3 LOC, docs/boards_area_project_delete.md regenerated via make docs. No changes to list.go, create.go, or area.go.

Tooling and Verification Checklist

  • gofmt / gofumpt on touched files
  • go test ./internal/cmd/boards/area/...
  • go test ./...
  • make lint
  • make docs

Reference Existing Patterns

  • internal/cmd/pipelines/variablegroup/delete/delete.go--yes confirmation pattern (Decision 7-8, 15); JSON view struct; single-line success output.
  • internal/cmd/security/permission/delete/delete.goprompter.Confirm pattern.
  • internal/cmd/boards/area/project/create/create.go (sibling feat: Implement azdo boards area project create command #204) — structural template: same path normalization, same client access, same JSON flag registration. Read it to ensure stylistic consistency.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture; copy structure (Decision fixture §4).
  • internal/cmd/boards/shared/path.goBuildClassificationPath + NormalizeClassificationPath (reuse, do not reimplement).
  • internal/mocks/workitemtracking_client_mock.go:196-207 — mock for DeleteClassificationNode (already generated, do not regenerate).
  • internal/azdo/factory.goClientFactory().WorkItemTracking(...) accessor (reuse).

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