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
Sub-issue of #141. Sibling of #204 (area project create), #206 (area project delete), and #143 (area project list). Hardened spec — do not re-derive decisions.
Command Description
Update an area path (classification node) in a project's area tree. v1 supports rename only; the SDK's CreateOrUpdateClassificationNode is the unified method, and the REST API distinguishes create vs update by the presence of id in the body.
The flow is two SDK calls:
GetClassificationNode to fetch the current node (extract Id and current Attributes).
CreateOrUpdateClassificationNode with body {id: <fetched>, name: <new>} to apply the update.
The SDK's URL is the same POST /_apis/wit/classificationnodes/Areas/{nodePath}?api-version=7.1 used by create; only the body shape differs.
Locked Decisions (do not re-derive)
#
Decision
Rationale
1
Use the vendored SDK. Two calls: GetClassificationNode (fetch current id and attributes) then CreateOrUpdateClassificationNode (with id in body). Mocks for both are already generated.
The SDK has no NodeId route for classification nodes; the id must be fetched. The REST API treats the body as update iff id is present.
2
--path is required. Identifies the node to update. Same normalization as create/delete.
Path-based identification matches the rest of the command set.
3
--name is required. New name for the node. Empty/whitespace rejected.
The v1 update surface for area is rename only.
4
No --start-date / --finish-date / --attributes for area. Areas don't carry attributes.
Scope discipline.
5
No --structure-group flag. Hardcoded to Areas.
Same as create/delete.
6
No --id flag. The id is fetched internally via GetClassificationNode.
Saves the user from manual lookup; avoids a 2-flag UX.
7
No confirmation prompt, no --yes. Update is non-destructive (the node persists; only the name changes).
Matches az boards work-item update.
8
No --reclassify-id. That's a delete-only concept.
Scope discipline.
9
Aliases: u, up per AGENTS.md.
Standard.
10
Path normalization via shared.BuildClassificationPath(scope.Project, true, "Area", opts.path). Same helper as create/delete.
One helper, one source of truth.
11
JSON output: raw *WorkItemClassificationNode via opts.exporter.Write(ios, res). No view struct.
Symmetric with create.
12
Table output: columns ID, NAME, PATH, HAS CHILDREN (single row). Mirrors create.
Symmetric.
13
No new SDK client, no new helper, no new package. Only the new update package + 3 LOC in project.go.
Mandate.
14
Mocks for GetClassificationNode (:370-381) and CreateOrUpdateClassificationNode (:106-118) are already generated. Do not regenerate.
Verified.
15
Defensive: if GetClassificationNode returns a node with Id == nil, return util.FlagErrorf("existing area path has no ID; cannot update"). The REST API treats a body without id as a create.
The SDK doesn't validate; the server would silently create a new node.
16
Debug log: organization, project, path, fetched id, new name.
Match create pattern.
Command Signature
azdo boards area project update [ORGANIZATION/]PROJECT --path --name
Aliases: u, up
Positional parsing via util.ParseProjectScope(ctx, scopeArg) (defined in internal/cmd/util/scope.go:78-108); errors wrapped with util.FlagErrorWrap.
Existing higher-level parents must already remain wired: boards → area → project → update.
Code Skeleton (canonical, copy verbatim)
§1. updateOptions struct
typeupdateOptionsstruct {
scopeArgstringpathstring// --path (node's own path)namestring// --name (new name)exporter util.Exporter
}
§2. NewCmd shape (no surprises)
funcNewCmd(ctx util.CmdContext) *cobra.Command {
opts:=&updateOptions{}
cmd:=&cobra.Command{
Use: "update [ORGANIZATION/]PROJECT --path --name ",
Short: "Update an area path in a project.",
Long: heredoc.Doc(` Update an area path (classification node) in a project. v1 supports renaming the node. The command fetches the current node, then issues an update with the new name. The path of the node cannot be changed in v1. `),
Example: heredoc.Doc(` # Rename a top-level area path azdo boards area project update Fabrikam --path Payments --name Billing # Rename a nested area path azdo boards area project update Fabrikam --path Payments/Refunds --name RefundsTeam # Emit JSON azdo boards area project update Fabrikam --path Payments --name Billing --json `),
Aliases: []string{"u", "up"},
Args: util.ExactArgs(1, "project argument required"),
RunE: func(cmd*cobra.Command, args []string) error {
opts.scopeArg=args[0]
returnrunUpdate(ctx, opts)
},
}
cmd.Flags().StringVar(&opts.path, "path", "", "Path of the area node to update (under /Area, leading /Area stripped).")
cmd.Flags().StringVar(&opts.name, "name", "", "New name for the area path (required).")
_=cmd.MarkFlagRequired("path")
_=cmd.MarkFlagRequired("name")
util.AddJSONFlags(cmd, &opts.exporter, []string{
"id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links",
})
returncmd
}
§3. runUpdate skeleton
funcrunUpdate(ctx util.CmdContext, opts*updateOptions) 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")
}
newName:=strings.TrimSpace(opts.name)
ifnewName=="" {
returnutil.FlagErrorf("--name must not be empty")
}
wit, err:=ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization)
iferr!=nil {
returnfmt.Errorf("failed to get classification client: %w", err)
}
// Step 1: fetch current state to obtain the id.getArgs:= workitemtracking.GetClassificationNodeArgs{
Project: types.ToPtr(scope.Project),
StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Areas),
Path: types.ToPtr(nodePath),
}
zap.L().Debug("fetching area path before update",
zap.String("organization", scope.Organization),
zap.String("project", scope.Project),
zap.String("path", nodePath),
)
existing, err:=wit.GetClassificationNode(ctx.Context(), getArgs)
iferr!=nil {
returnfmt.Errorf("failed to fetch area path: %w", err)
}
ifexisting==nil||existing.Id==nil {
returnutil.FlagErrorf("existing area path has no ID; cannot update")
}
// Step 2: send the update with id in the body so the REST API treats it as an update.updateArgs:= workitemtracking.CreateOrUpdateClassificationNodeArgs{
Project: types.ToPtr(scope.Project),
StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Areas),
Path: types.ToPtr(nodePath),
PostedNode: &workitemtracking.WorkItemClassificationNode{
Id: existing.Id,
Name: types.ToPtr(newName),
},
}
zap.L().Debug("updating area path",
zap.String("organization", scope.Organization),
zap.String("project", scope.Project),
zap.String("path", nodePath),
zap.Int("id", *existing.Id),
zap.String("newName", newName),
)
res, err:=wit.CreateOrUpdateClassificationNode(ctx.Context(), updateArgs)
iferr!=nil {
returnfmt.Errorf("failed to update area path: %w", err)
}
ios.StopProgressIndicator()
ifopts.exporter!=nil {
returnopts.exporter.Write(ios, res)
}
tp, err:=ctx.Printer("table")
iferr!=nil {
returnerr
}
tp.AddColumns("ID", "NAME", "PATH", "HAS CHILDREN")
tp.AddField(strconv.Itoa(types.GetValue(res.Id, 0)))
tp.AddField(types.GetValue(res.Name, ""))
tp.AddField(shared.NormalizeClassificationPath(types.GetValue(res.Path, "")))
iftypes.GetValue(res.HasChildren, false) {
tp.AddField("true")
} else {
tp.AddField("false")
}
tp.EndRow()
returntp.Render()
}
§4. Test fixture (copy from workitem/list/list_test.go:765-844)
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. Add update_test.go with the following table-driven / behaviour tests, all using t.Parallel() and gomock (require for preconditions, assert for verifications):
TestNewCmd_RegistersAsUpdateLeaf — asserts cmd.Name() == "update", cmd.Aliases contains u, up, cmd.Use starts with update [ORGANIZATION/]PROJECT.
TestRunUpdate_StructureGroupIsAreas — asserts *args.StructureGroup == workitemtracking.TreeStructureGroupValues.Areas (literal "areas") for both SDK calls.
TestRunUpdate_FetchedIdInBody — stubs GetClassificationNode to return *Id == 99; asserts the update body's *Id == 99.
TestRunUpdate_NewNameInBody — asserts the update body's *Name == "Billing".
TestRunUpdate_MissingIdInFetchedNode — stubs GetClassificationNode to return *WorkItemClassificationNode{Id: nil}; asserts util.FlagErrorf with existing area path has no ID. The SDK must NOT be called for the update.
TestRunUpdate_GetClassificationNodeError — stubs GetClassificationNode to return error; asserts wrapped error; the SDK is NOT called for the update.
TestRunUpdate_SDKError — stubs CreateOrUpdateClassificationNode to return error; asserts wrapped error.
TestRunUpdate_ClientFactoryError — stubs factory to return error; asserts wrapped error; both SDK calls are NOT made.
TestRunUpdate_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 runUpdate (already short).
Reuse shared.BuildClassificationPath(scope.Project, true, "Area", opts.path) for path normalization; the helper already URL-escapes segments.
Reuse shared.NormalizeClassificationPath for the response table Path column.
SDK calls only: wit.GetClassificationNode(ctx.Context(), getArgs) then wit.CreateOrUpdateClassificationNode(ctx.Context(), updateArgs). The second call passes PostedNode.Id = existing.Id so the REST API treats it as an update (Decision 1).
Progress indicator: ios.StartProgressIndicator() + defer ios.StopProgressIndicator(); call ios.StopProgressIndicator() immediately before the table render.
Output split: JSON via opts.exporter.Write(ios, res) passing the raw SDK *WorkItemClassificationNode; table via ctx.Printer("table") with AddColumns/AddField/EndRow/Render.
Debug logs at the point of each SDK call: organization, project, path, id, newName.
Target delta: update.go ≤ ~130 LOC, update_test.go ≤ ~450 LOC (20 tests), parent project.go +3 LOC, docs/boards_area_project_update.md regenerated via make docs. No changes to list.go, create.go, delete.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/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, same progress lifecycle.
Sub-issue of #141. Sibling of #204 (
area project create), #206 (area project delete), and #143 (area project list). Hardened spec — do not re-derive decisions.Command Description
Update an area path (classification node) in a project's area tree. v1 supports rename only; the SDK's
CreateOrUpdateClassificationNodeis the unified method, and the REST API distinguishes create vs update by the presence ofidin the body.The flow is two SDK calls:
GetClassificationNodeto fetch the current node (extractIdand currentAttributes).CreateOrUpdateClassificationNodewith body{id: <fetched>, name: <new>}to apply the update.The SDK's URL is the same
POST /_apis/wit/classificationnodes/Areas/{nodePath}?api-version=7.1used by create; only the body shape differs.Locked Decisions (do not re-derive)
GetClassificationNode(fetch current id and attributes) thenCreateOrUpdateClassificationNode(withidin body). Mocks for both are already generated.idis present.--pathis required. Identifies the node to update. Same normalization as create/delete.--nameis required. New name for the node. Empty/whitespace rejected.--start-date/--finish-date/--attributesfor area. Areas don't carry attributes.--structure-groupflag. Hardcoded toAreas.--idflag. The id is fetched internally viaGetClassificationNode.--yes. Update is non-destructive (the node persists; only the name changes).az boards work-item update.--reclassify-id. That's a delete-only concept.u,upper AGENTS.md.shared.BuildClassificationPath(scope.Project, true, "Area", opts.path). Same helper as create/delete.*WorkItemClassificationNodeviaopts.exporter.Write(ios, res). No view struct.ID, NAME, PATH, HAS CHILDREN(single row). Mirrors create.updatepackage + 3 LOC inproject.go.GetClassificationNode(:370-381) andCreateOrUpdateClassificationNode(:106-118) are already generated. Do not regenerate.GetClassificationNodereturns a node withId == nil, returnutil.FlagErrorf("existing area path has no ID; cannot update"). The REST API treats a body withoutidas a create.Command Signature
u,uputil.ParseProjectScope(ctx, scopeArg)(defined ininternal/cmd/util/scope.go:78-108); errors wrapped withutil.FlagErrorWrap.Flags (mapped to SDK/REST)
--path(required)GetClassificationNodeArgs.Path(URL) andCreateOrUpdateClassificationNodeArgs.Path(URL)<Project>/<Area>segments stripped--name(required)CreateOrUpdateClassificationNodeArgs.PostedNode.Name(body)--json(optional)*WorkItemClassificationNodeas JSON--jq,--template(optional)JSON Output Contract
Pass the raw
*WorkItemClassificationNodereturned by the SDK (Decision 11). This is the updated node (server response), not the fetched one.Table default columns:
ID, NAME, PATH, HAS CHILDREN(Decision 12).Command Wiring
internal/cmd/boards/area/project/updateupdate.go—NewCmd(ctx util.CmdContext) *cobra.Command+updateOptions+runUpdateupdate_test.go— table-driven gomock testsinternal/cmd/boards/area/project/project.go:boards→area→project→update.Code Skeleton (canonical, copy verbatim)
§1.
updateOptionsstruct§2.
NewCmdshape (no surprises)§3.
runUpdateskeleton§4. Test fixture (copy from
workitem/list/list_test.go:765-844)API Surface
Reuse the already-vendored client. No new SDK clients required.
workitemtracking.Client.GetClassificationNode→ Classification Nodes - Get (REST 7.1)workitemtracking.Client.CreateOrUpdateClassificationNode→ Classification Nodes - Create Or Update (REST 7.1)Both mocks are already generated. 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-844. Addupdate_test.gowith the following table-driven / behaviour tests, all usingt.Parallel()andgomock(requirefor preconditions,assertfor verifications):TestNewCmd_RegistersAsUpdateLeaf— assertscmd.Name() == "update",cmd.Aliasescontainsu,up,cmd.Usestarts withupdate [ORGANIZATION/]PROJECT.TestNewCmd_PathFlagRequired— runscmd.SetArgs([]string{"Fabrikam", "--name", "X"})+cmd.Execute(); asserts cobraMarkFlagRequirederror mentioningpath.TestNewCmd_NameFlagRequired— runscmd.SetArgs([]string{"Fabrikam", "--path", "Payments"})+cmd.Execute(); asserts cobraMarkFlagRequirederror mentioningname.TestRunUpdate_EmptyPathFlag— sets--path " "; assertsutil.FlagErrorfwith--path must not be empty(or root-rejection equivalent).TestRunUpdate_RootNode_Rejected— sets--path ""and--path "Fabrikam/Area"; assertsutil.FlagErrorfbecausenodePathis empty.TestRunUpdate_EmptyNameFlag— sets--name " "; assertsutil.FlagErrorfwith--name must not be empty.TestRunUpdate_DefaultScope— sets--path Payments --name Billing; stubsGetClassificationNodeto return*WorkItemClassificationNode{Id: 42, Name: "Payments", Path: "Fabrikam/Area/Payments"}; stubsCreateOrUpdateClassificationNode; asserts both SDK calls used*StructureGroup == TreeStructureGroupValues.Areas,*Project == "Fabrikam",*Path == "Payments"; the update body's*Id == 42and*Name == "Billing".TestRunUpdate_NestedPath— sets--path "Payments/Sub" --name "Sub2"; assertsargs.Path == "Payments/Sub"for both SDK calls.TestRunUpdate_PathNormalizationStripsProjectAndArea— sets--path "Fabrikam/Area/Payments/Sub" --name X; assertsargs.Path == "Payments/Sub".TestRunUpdate_PathURLEscaping— sets--path "My Team/Sub Team" --name X; assertsargs.Path == "My%20Team/Sub%20Team".TestRunUpdate_StructureGroupIsAreas— asserts*args.StructureGroup == workitemtracking.TreeStructureGroupValues.Areas(literal"areas") for both SDK calls.TestRunUpdate_FetchedIdInBody— stubsGetClassificationNodeto return*Id == 99; asserts the update body's*Id == 99.TestRunUpdate_NewNameInBody— asserts the update body's*Name == "Billing".TestRunUpdate_MissingIdInFetchedNode— stubsGetClassificationNodeto return*WorkItemClassificationNode{Id: nil}; assertsutil.FlagErrorfwithexisting area path has no ID. The SDK must NOT be called for the update.TestRunUpdate_GetClassificationNodeError— stubsGetClassificationNodeto return error; asserts wrapped error; the SDK is NOT called for the update.TestRunUpdate_SDKError— stubsCreateOrUpdateClassificationNodeto return error; asserts wrapped error.TestRunUpdate_ClientFactoryError— stubs factory to return error; asserts wrapped error; both SDK calls are NOT made.TestRunUpdate_ProjectScopeParsing— table-driven:[ORG/]PROJECT,PROJECT, invalid ("org/proj/extra"), empty.TestRunUpdate_InvalidProjectScope— assertsutil.FlagErrorWrapreturned.TestRunUpdate_TableOutput— mocks return updated*WorkItemClassificationNode{Id: 42, Name: "Billing", Path: "Fabrikam/Area/Payments", HasChildren: false}; parses stdout; asserts rowID=42, NAME=Billing, PATH=Fabrikam/Area/Payments, HAS CHILDREN=false.TestRunUpdate_JSONOutput— sets--json; mocks return updated node; asserts JSON containsid,name,path,_links,hasChildren,url,identifier,structureType,attributes.TestRunUpdate_OrganizationFromConfigDefault— when scopeArg isPROJECT(no org), assertsclientFact.WorkItemTracking(ctx, defaultOrg)is called with the configured default.Phase 2 — GREEN (minimal implementation). Strict reuse rules:
runUpdate(already short).util.ParseProjectScope,util.AddJSONFlags,util.FlagErrorf/FlagErrorWrap,util.ExactArgs,types.GetValue,types.ToPtr,ctx.Printer("table"),ios.StartProgressIndicator/StopProgressIndicator,iostreams.Testas-is.shared.BuildClassificationPath(scope.Project, true, "Area", opts.path)for path normalization; the helper already URL-escapes segments.shared.NormalizeClassificationPathfor the response tablePathcolumn.wit.GetClassificationNode(ctx.Context(), getArgs)thenwit.CreateOrUpdateClassificationNode(ctx.Context(), updateArgs). The second call passesPostedNode.Id = existing.Idso the REST API treats it as an update (Decision 1).ios.StartProgressIndicator()+defer ios.StopProgressIndicator(); callios.StopProgressIndicator()immediately before the table render.opts.exporter.Write(ios, res)passing the raw SDK*WorkItemClassificationNode; table viactx.Printer("table")withAddColumns/AddField/EndRow/Render.Target delta:
update.go≤ ~130 LOC,update_test.go≤ ~450 LOC (20 tests), parentproject.go+3 LOC,docs/boards_area_project_update.mdregenerated viamake docs. No changes tolist.go,create.go,delete.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/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, same progress lifecycle.internal/cmd/boards/area/project/delete/delete.go(sibling feat: Implementazdo boards area project deletecommand #206) — destructive-command sibling; mirror the SDK call shape and progress lifecycle.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:370-381— mock forGetClassificationNode(already generated, do not regenerate).internal/mocks/workitemtracking_client_mock.go:106-118— mock forCreateOrUpdateClassificationNode(already generated, do not regenerate).internal/azdo/factory.go—ClientFactory().WorkItemTracking(...)accessor (reuse).References