You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Default output: single line Deleted area path: <path> via fmt.Fprintf(ios.Out, ...). When --reclassify-id is set, append \nReclassified work items to: <id>.
Default output: single line Deleted area path: <path> (Decision 13). When --reclassify-id is set, append \nReclassified work items to: <id>.
funcNewCmd(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]
returnrunDelete(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"})
returncmd
}
§3. runDelete skeleton
typedeleteResultstruct {
Deletedbool`json:"deleted"`Pathstring`json:"path"`ReclassifyID*int`json:"reclassifyId,omitempty"`
}
funcrunDelete(ctx util.CmdContext, opts*deleteOptions) error {
ios, err:=ctx.IOStreams()
iferr!=nil {
returnerr
}
ios.StartProgressIndicator()
deferios.StopProgressIndicator()
scope, err:=util.ParseProjectScope(ctx, opts.scopeArg)
iferr!=nil {
returnutil.FlagErrorWrap(err)
}
nodePath, err:=shared.BuildClassificationPath(scope.Project, true, "Area", opts.path)
iferr!=nil {
returnutil.FlagErrorf("invalid --path: %w", err)
}
ifnodePath=="" {
returnutil.FlagErrorf("--path must reference a child of /Area, not the area root")
}
// Confirmationif!opts.yes {
if!ios.CanPrompt() {
returnutil.FlagErrorf("--yes required when not running interactively")
}
ios.StopProgressIndicator()
prompter, err:=ctx.Prompter()
iferr!=nil {
returnerr
}
prompt:=fmt.Sprintf("Delete area path %q from project %s/%s?", nodePath, scope.Organization, scope.Project)
confirmed, err:=prompter.Confirm(prompt, false)
iferr!=nil {
returnerr
}
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),
)
returnutil.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)
iferr!=nil {
returnfmt.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),
}
ifopts.reclassifyID!=0 {
args.ReclassifyId=types.ToPtr(opts.reclassifyID)
}
iferr:=wit.DeleteClassificationNode(ctx.Context(), args); err!=nil {
returnfmt.Errorf("failed to delete area path: %w", err)
}
ios.StopProgressIndicator()
ifopts.exporter!=nil {
result:=deleteResult{
Deleted: true,
Path: nodePath,
}
ifopts.reclassifyID!=0 {
result.ReclassifyID=types.ToPtr(opts.reclassifyID)
}
returnopts.exporter.Write(ios, result)
}
fmt.Fprintf(ios.Out, "Deleted area path: %s\n", nodePath)
ifopts.reclassifyID!=0 {
fmt.Fprintf(ios.Out, "Reclassified work items to: %d\n", opts.reclassifyID)
}
returnnil
}
§4. Test fixture (copy from workitem/list/list_test.go:765-844; add prompter mock from security/permission/delete)
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.
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_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 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 conditionallyargs.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.
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.
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:
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)
workitemtracking.Client.DeleteClassificationNode(not raw HTTP). Mock is already generated.list.go).--pathis 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 viaBuildClassificationPath. Empty/whitespace rejected withutil.FlagErrorf.DeleteClassificationNodeArgs.Pathis the only delete-target mechanism; the SDK exposes no NodeId route.--reclassify-id. Maps toDeleteClassificationNodeArgs.ReclassifyId. When set, work items are moved to that node before deletion.$reclassifyIdquery param.--reclassify-pathflag in v1. SDK only supportsReclassifyId(int), not a path.--idflag. The SDK'sDeleteClassificationNodeArgshas no NodeId field; only path-based deletion is supported.--forceflag. Use--yesto skip confirmation (existing pattern inpipelines/variablegroup/delete,serviceendpoint/delete,security/permission/delete).--yes, -yviaBoolVarP. Non-TTY + no--yesreturnsutil.FlagErrorf("--yes required when not running interactively")— mirrorsinternal/cmd/pipelines/variablegroup/delete/delete.go:125.prompter.Confirm(...). User cancel returnsutil.ErrCancel. Stop the progress indicator before the prompt and restart after — mirrorsinternal/cmd/pipelines/variablegroup/delete/delete.go:127,141.d,del,rmper AGENTS.md destructive convention.shared.BuildClassificationPath(scope.Project, true, "Area", opts.path). URL-escapes segments; returns the path portion.StructureGrouphardcoded to&workitemtracking.TreeStructureGroupValues.Areas(literal"areas"). No--structure-groupflag.deleteResult{Deleted bool, Path string, ReclassifyID *int}viautil.AddJSONFlags.ReclassifyIDis*intwithomitemptyso it only appears when the flag was set.internal/cmd/pipelines/variablegroup/delete/delete.go:25-28.Deleted area path: <path>viafmt.Fprintf(ios.Out, ...). When--reclassify-idis set, append\nReclassified work items to: <id>.internal/cmd/pipelines/variablegroup/delete/delete.go:168simplicity.canceledevent when user declines.deletepackage + 3 LOC inproject.goto wire it.DeleteClassificationNodeis already generated atinternal/mocks/workitemtracking_client_mock.go:196-207. Do not regenerate.Command Signature
d,del,rmutil.ParseProjectScope(ctx, scopeArg)(defined ininternal/cmd/util/scope.go:78-108); errors wrapped withutil.FlagErrorWrap.Flags (mapped to SDK/REST)
--path(required)DeleteClassificationNodeArgs.Path(URL segment, node's own path)<Project>/<Area>segments stripped--reclassify-id(optional)DeleteClassificationNodeArgs.ReclassifyId(int, REST$reclassifyIdquery param)--yes, -y(optional)JSON Output Contract
View struct (only used in JSON path; the SDK returns only
error):Default output: single line
Deleted area path: <path>(Decision 13). When--reclassify-idis set, append\nReclassified work items to: <id>.Command Wiring
internal/cmd/boards/area/project/deletedelete.go—NewCmd(ctx util.CmdContext) *cobra.Command+deleteOptions+runDeletedelete_test.go— table-driven gomock testsinternal/cmd/boards/area/project/project.go:boards→area→project→delete.Code Skeleton (canonical, copy verbatim)
§1.
deleteOptionsstruct§2.
NewCmdshape (no surprises)§3.
runDeleteskeleton§4. Test fixture (copy from
workitem/list/list_test.go:765-844; add prompter mock fromsecurity/permission/delete)(The fixture takes a
canPromptbool 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.DeleteClassificationNode→ Classification Nodes - Delete (REST 7.1)Mock for
DeleteClassificationNodeis already generated atinternal/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, andgolang-stretchr-testifyskills as the source of truth for structure, flag wiring, args validators, table-driven tests, and mock verification.Phase 1 — RED (tests first). Mirror
setupFakeDepsfrominternal/cmd/boards/workitem/list/list_test.go:765-844and the prompter mock wiring frominternal/cmd/security/permission/delete/delete_test.go. Adddelete_test.gowith the following table-driven / behaviour tests, all usingt.Parallel()andgomock(requirefor preconditions,assertfor verifications):TestNewCmd_RegistersAsDeleteLeaf— assertscmd.Name() == "delete",cmd.Aliasescontainsd,del,rm,cmd.Usestarts withdelete [ORGANIZATION/]PROJECT.TestNewCmd_PathFlagRequired— runscmd.SetArgs([]string{"Fabrikam"})+cmd.Execute(); asserts cobraMarkFlagRequirederror mentioningpath.TestRunDelete_EmptyPathFlag— sets--path " "; assertsutil.FlagErrorfreturned with--path must not be empty.TestRunDelete_RootNode_Rejected— sets--path ""and--path "Fabrikam/Area"(root only); assertsutil.FlagErrorfbecausenodePathends up empty.TestRunDelete_DefaultScope— sets--path Payments --yes(non-TTY); asserts capturedargs.Path == "Payments",*args.StructureGroup == TreeStructureGroupValues.Areas(literal"areas"),*args.Project == "Fabrikam",args.ReclassifyId == nil.TestRunDelete_NestedPath— sets--path "Payments/Sub" --yes; assertsargs.Path == "Payments/Sub".TestRunDelete_PathNormalizationStripsProjectAndArea— sets--path "Fabrikam/Area/Payments/Sub" --yes; assertsargs.Path == "Payments/Sub".TestRunDelete_PathURLEscaping— sets--path "My Team/Sub Team" --yes; assertsargs.Path == "My%20Team/Sub%20Team".TestRunDelete_StructureGroupIsAreas— asserts*args.StructureGroup == workitemtracking.TreeStructureGroupValues.Areas(literal"areas").TestRunDelete_ReclassifyId_DefaultNil— no--reclassify-id; assertsargs.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— assertsutil.FlagErrorWrapreturned.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) — assertsprompter.Confirmis not called, SDK is called.TestRunDelete_ConfirmationPrompt_Yes(TTY, no--yes, user confirms) — assertsprompter.Confirmcalled with deletion prompt; SDK is called.TestRunDelete_ConfirmationPrompt_No(TTY, no--yes, user declines) — assertsprompter.Confirmreturns false; SDK is not called; returnsutil.ErrCancel.TestRunDelete_NonTTY_NoYes_ReturnsError(non-TTY, no--yes) — assertsutil.FlagErrorfwith--yes required; SDK is not called.TestRunDelete_DefaultOutput— asserts stdout containsDeleted area path: Payments\n.TestRunDelete_DefaultOutput_WithReclassify— asserts stdout containsDeleted area path: Payments\nReclassified work items to: 42\n.TestRunDelete_JSONOutput— sets--json; asserts JSON contains{"deleted":true,"path":"Payments"}and noreclassifyIdkey.TestRunDelete_JSONOutput_WithReclassify— sets--json --reclassify-id 42; asserts JSON contains{"deleted":true,"path":"Payments","reclassifyId":42}.TestRunDelete_OrganizationFromConfigDefault— when scopeArg isPROJECT(no org), assertsclientFact.WorkItemTracking(ctx, defaultOrg)is called with the configured default.Phase 2 — GREEN (minimal implementation). Strict reuse rules:
runDelete(already short).util.ParseProjectScope,util.AddJSONFlags,util.FlagErrorf/FlagErrorWrap,util.ExactArgs,types.GetValue,types.ToPtr,util.ErrCancel,ios.StartProgressIndicator/StopProgressIndicator,iostreams.Testas-is.shared.BuildClassificationPath(scope.Project, true, "Area", opts.path)for path normalization; the helper already URL-escapes segments.wit.DeleteClassificationNode(ctx.Context(), args)withargs.Project,args.StructureGroup = &workitemtracking.TreeStructureGroupValues.Areas,args.Path, and conditionallyargs.ReclassifyId.ios.StartProgressIndicator()+defer ios.StopProgressIndicator(); callios.StopProgressIndicator()before the confirmation prompt andios.StartProgressIndicator()after a confirmed prompt (mirrorsinternal/cmd/pipelines/variablegroup/delete/delete.go:127,141).!opts.yesand!ios.CanPrompt(); on cancel, returnutil.ErrCancel.opts.exporter.Write(ios, deleteResult{...})(constructed inrunDelete, NOT from the SDK return — the SDK returns onlyerror); default viafmt.Fprintf(ios.Out, ...).canceledevent when the user declines.Target delta:
delete.go≤ ~140 LOC,delete_test.go≤ ~500 LOC (22 tests), parentproject.go+3 LOC,docs/boards_area_project_delete.mdregenerated viamake docs. No changes tolist.go,create.go, orarea.go.Tooling and Verification Checklist
gofmt/gofumpton touched filesgo test ./internal/cmd/boards/area/...go test ./...make lintmake docsReference Existing Patterns
internal/cmd/pipelines/variablegroup/delete/delete.go—--yesconfirmation pattern (Decision 7-8, 15); JSON view struct; single-line success output.internal/cmd/security/permission/delete/delete.go—prompter.Confirmpattern.internal/cmd/boards/area/project/create/create.go(sibling feat: Implementazdo boards area project createcommand #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-844—setupFakeDeps/stub*fixture; copy structure (Decision fixture §4).internal/cmd/boards/shared/path.go—BuildClassificationPath+NormalizeClassificationPath(reuse, do not reimplement).internal/mocks/workitemtracking_client_mock.go:196-207— mock forDeleteClassificationNode(already generated, do not regenerate).internal/azdo/factory.go—ClientFactory().WorkItemTracking(...)accessor (reuse).References