Skip to content

feat: Implement azdo boards iteration project delete command #207

Description

@tmeckel

Sub-issue of #142. Sibling of #205 (iteration project create) and #144 (iteration project list). Hardened spec — do not re-derive decisions. Mirrors #206 (area project delete) with the structure-group and scope-name swaps only.

Command Description

Delete an iteration (sprint) from a project's iteration tree. The endpoint is the Classification Nodes REST 7.1 DELETE:

DELETE https://dev.azure.com/{organization}/{project}/_apis/wit/classificationnodes/Iterations/{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 Iterations (lowercase literal "iterations"). The scope name passed to BuildClassificationPath is "Iteration" (singular), matching the list/create convention.

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 #205, #209; 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>/<Iteration> 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, "Iteration", opts.path) (note: "Iteration" is the singular scope name matching list/create). URL-escapes segments; returns the path portion. One helper, one source of truth.
11 StructureGroup hardcoded to &workitemtracking.TreeStructureGroupValues.Iterations (literal "iterations"). No --structure-group flag. Scope discipline; area lives in its own command (#206).
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 iteration: <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 iteration 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>/<Iteration> 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 iteration: <path> (Decision 13). When --reclassify-id is set, append \nReclassified work items to: <id>.

Command Wiring

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

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 iteration from a project.",
        Long: heredoc.Doc(`
            Delete an iteration (sprint) 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 iteration
            azdo boards iteration project delete Fabrikam --path "Sprint 1" --yes

            # Delete a nested iteration with a confirmation prompt
            azdo boards iteration project delete Fabrikam --path "Release 2025/Sprint 1"

            # Reclassify work items to node 42 before deletion
            azdo boards iteration project delete Fabrikam --path "Sprint 1" \
                --reclassify-id 42 --yes

            # Emit JSON
            azdo boards iteration project delete Fabrikam --path "Sprint 1" --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 iteration to delete (under /Iteration, leading /Iteration 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, "Iteration", opts.path)
    if err != nil {
        return util.FlagErrorf("invalid --path: %w", err)
    }
    if nodePath == "" {
        return util.FlagErrorf("--path must reference a child of /Iteration, not the iteration 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 iteration %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("iteration 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 iteration",
        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.Iterations),
        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 iteration: %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 iteration: %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.

  • workitemtracking.Client.DeleteClassificationNodeClassification Nodes - Delete (REST 7.1)
  • Constant: workitemtracking.TreeStructureGroupValues.Iterations = "iterations" (lowercase; at vendor/.../workitemtracking/models.go:967)

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/Iteration" (root only); asserts util.FlagErrorf because nodePath ends up empty.
  • TestRunDelete_DefaultScope — sets --path "Sprint 1" --yes (non-TTY); asserts captured args.Path == "Sprint%201", *args.StructureGroup == TreeStructureGroupValues.Iterations (literal "iterations"), *args.Project == "Fabrikam", args.ReclassifyId == nil.
  • TestRunDelete_NestedPath — sets --path "Release 2025/Sprint 1" --yes; asserts args.Path == "Release%202025/Sprint%201".
  • TestRunDelete_PathNormalizationStripsProjectAndIteration — sets --path "Fabrikam/Iteration/Release 2025/Sprint 1" --yes; asserts args.Path == "Release%202025/Sprint%201".
  • TestRunDelete_PathURLEscaping — sets --path "My Sprint/Sub Sprint" --yes; asserts args.Path == "My%20Sprint/Sub%20Sprint".
  • TestRunDelete_StructureGroupIsIterations — asserts *args.StructureGroup == workitemtracking.TreeStructureGroupValues.Iterations (literal "iterations").
  • 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 iteration: Sprint 1\n.
  • TestRunDelete_DefaultOutput_WithReclassify — asserts stdout contains Deleted iteration: Sprint 1\nReclassified work items to: 42\n.
  • TestRunDelete_JSONOutput — sets --json; asserts JSON contains {"deleted":true,"path":"Sprint 1"} and no reclassifyId key.
  • TestRunDelete_JSONOutput_WithReclassify — sets --json --reclassify-id 42; asserts JSON contains {"deleted":true,"path":"Sprint 1","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, "Iteration", 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.Iterations, 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_iteration_project_delete.md regenerated via make docs. No changes to list.go, create.go, or iteration.go.

Tooling and Verification Checklist

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

Reference Existing Patterns

  • #206 (area delete) — the primary mirror; same SDK call, same confirmation pattern, same JSON shape.
  • 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/iteration/project/create/create.go (sibling feat: Implement azdo boards iteration project create command #205) — structural template: same path normalization, same client access, same JSON flag registration. Read it to ensure stylistic consistency on iteration wording.
  • 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

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions