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 #143 (area project list), #206 (area project delete), and #208 (area project update). Hardened spec — do not re-derive decisions.
Command Description
Create a new area path (classification node) under a project's area tree. The body is {name: "..."} posted to the Classification Nodes REST 7.1 endpoint.
POST https://dev.azure.com/{organization}/{project}/_apis/wit/classificationnodes/Areas/{parentPath}?api-version=7.1Content-Type: application/json
For v1 the structureGroup is hardcoded to Areas (lowercase literal "areas"). Iteration gets its own command (#205). The parentPath segment is optional; omitting it creates a node at the project root (directly under <Project>/Area). The body always carries name only (no id — move semantics deferred to #208/#209 update).
Locked Decisions (do not re-derive)
#
Decision
Rationale
1
Use the vendored SDK workitemtracking.Client.CreateOrUpdateClassificationNode (not raw HTTP). Mock is already generated.
Consistent with #206, #208; raw HTTP is the outlier (see list.go). Reuse avoids new connection plumbing.
2
--name is the only required flag. Empty/whitespace rejected with util.FlagErrorf.
Names are the only mutable surface for create.
3
--path is optional. It specifies the parent area path under <Project>/Area. Empty → new node at project root.
Symmetric with how --path is used in #143 for subtree listing.
4
Path normalization via shared.BuildClassificationPath(scope.Project, true, "Area", opts.path) — same helper as #143. Strips leading <Project> and Area segments; URL-escapes remaining segments; returns ("", nil) for empty input.
Guarantees symmetric round-trip with list output; one helper, one source of truth.
5
StructureGroup is hardcoded to &workitemtracking.TreeStructureGroupValues.Areas. No --structure-group flag. Iteration gets its own command.
Scope discipline.
6
No move-by-id semantics in v1. Always send Name in PostedNode; never Id.
Azure DevOps's {id: int} body shape would clobber the new-node contract; defer to #208 (update).
7
No --attributes flag in v1. Areas don't need it; iterations do (#205).
Out of scope.
8
JSON output passes the raw SDK *WorkItemClassificationNode to opts.exporter.Write. No view struct.
Pass the raw *workitemtracking.WorkItemClassificationNode returned by the SDK (Decision 8). This is the created node (server response), not the request.
Table default columns: ID, NAME, PATH, HAS CHILDREN (Decision 9).
funcNewCmd(ctx util.CmdContext) *cobra.Command {
opts:=&createOptions{}
cmd:=&cobra.Command{
Use: "create [ORGANIZATION/]PROJECT",
Short: "Create an area path in a project.",
Long: heredoc.Doc(` Create a new area path (classification node) under a project. By default the new node is created at the project root. Use --path to create a node under a specific parent area. `),
Example: heredoc.Doc(` # Create a top-level area path in Fabrikam azdo boards area project create Fabrikam --name Payments # Create a nested area path under Payments in myorg/Fabrikam azdo boards area project create myorg/Fabrikam --name Refunds --path Payments # Emit JSON azdo boards area project create Fabrikam --name Web --json `),
Aliases: []string{"c", "cr"},
Args: util.ExactArgs(1, "project argument required"),
RunE: func(cmd*cobra.Command, args []string) error {
opts.scopeArg=args[0]
returnrunCreate(ctx, opts)
},
}
cmd.Flags().StringVar(&opts.name, "name", "", "Name of the new area path (required).")
cmd.Flags().StringVar(&opts.path, "path", "", "Parent area path under /Area. Omit to create at the project root.")
_=cmd.MarkFlagRequired("name")
util.AddJSONFlags(cmd, &opts.exporter, []string{
"id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links",
})
returncmd
}
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 create_test.go with the following table-driven / behaviour tests, all using t.Parallel() and gomock (require for preconditions, assert for verifications):
TestNewCmd_RegistersAsCreateLeaf — asserts cmd.Name() == "create", cmd.Aliases contains c and cr, cmd.Use starts with create [ORGANIZATION/]PROJECT.
TestRunCreate_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 runCreate (already short).
Reuse shared.BuildClassificationPath(scope.Project, true, "Area", opts.path) for parent-path normalization; the helper already URL-escapes segments.
Reuse shared.NormalizeClassificationPath for the response table Path column (REST returns backslashes; convert to forward slashes).
SDK call only: wit.CreateOrUpdateClassificationNode(ctx.Context(), args) with args.PostedNode.Name, args.Project, args.StructureGroup = &workitemtracking.TreeStructureGroupValues.Areas, and conditionallyargs.Path.
Progress indicator: ios.StartProgressIndicator() + defer ios.StopProgressIndicator(); call ios.StopProgressIndicator() immediately before the table render (mirrors internal/cmd/repo/create/create.go).
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 log at the point of the SDK call: organization, project, name, parentPath (mirrors list.go:140-146).
Target delta: create.go ≤ ~110 LOC, create_test.go ≤ ~350 LOC (16 tests), parent project.go +3 LOC, docs/boards_area_project_create.md regenerated via make docs. No changes to list.go, delete.go, update.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/list/list.go — uses raw HTTP (NOT a template; SDK is preferred). Reuses shared.BuildClassificationPath (Decision 4) for the list endpoint URL construction.
Sub-issue of #141. Sibling of #143 (
area project list), #206 (area project delete), and #208 (area project update). Hardened spec — do not re-derive decisions.Command Description
Create a new area path (classification node) under a project's area tree. The body is
{name: "..."}posted to the Classification Nodes REST 7.1 endpoint.For v1 the structureGroup is hardcoded to
Areas(lowercase literal"areas"). Iteration gets its own command (#205). TheparentPathsegment is optional; omitting it creates a node at the project root (directly under<Project>/Area). The body always carriesnameonly (noid— move semantics deferred to #208/#209 update).Locked Decisions (do not re-derive)
workitemtracking.Client.CreateOrUpdateClassificationNode(not raw HTTP). Mock is already generated.list.go). Reuse avoids new connection plumbing.--nameis the only required flag. Empty/whitespace rejected withutil.FlagErrorf.--pathis optional. It specifies the parent area path under<Project>/Area. Empty → new node at project root.--pathis used in #143 for subtree listing.shared.BuildClassificationPath(scope.Project, true, "Area", opts.path)— same helper as #143. Strips leading<Project>andAreasegments; URL-escapes remaining segments; returns("", nil)for empty input.StructureGroupis hardcoded to&workitemtracking.TreeStructureGroupValues.Areas. No--structure-groupflag. Iteration gets its own command.NameinPostedNode; neverId.{id: int}body shape would clobber the new-node contract; defer to #208 (update).--attributesflag in v1. Areas don't need it; iterations do (#205).*WorkItemClassificationNodetoopts.exporter.Write. No view struct.ID, NAME, PATH, HAS CHILDREN(single row).Level/ParentPath.area project delete(#206).az boards work-item create.createpackage + 3 LOC inproject.goto wire it.CreateOrUpdateClassificationNodeis already generated atinternal/mocks/workitemtracking_client_mock.go:106-118. Do not regenerate.--namerejects whitespace-only viastrings.TrimSpace(opts.name) == ""check, not via cobraMarkFlagRequiredalone.--depthor--include-ids. Create returns one node; those flags belong to list.Command Signature
c,crutil.ParseProjectScope(ctx, scopeArg)(defined ininternal/cmd/util/scope.go:78-108); errors wrapped withutil.FlagErrorWrap.Flags (mapped to SDK/REST)
--name(required)WorkItemClassificationNode.Name--path(optional)CreateOrUpdateClassificationNodeArgs.Path(URL segment, parent)JSON Output Contract
Pass the raw
*workitemtracking.WorkItemClassificationNodereturned by the SDK (Decision 8). This is the created node (server response), not the request.Table default columns:
ID, NAME, PATH, HAS CHILDREN(Decision 9).Command Wiring
internal/cmd/boards/area/project/createcreate.go—NewCmd(ctx util.CmdContext) *cobra.Command+createOptions+runCreatecreate_test.go— table-driven gomock testsinternal/cmd/boards/area/project/project.go:boards→area→project→create.Code Skeleton (canonical, copy verbatim)
§1.
createOptionsstruct§2.
NewCmdshape (no surprises)§3.
runCreateskeleton§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.CreateOrUpdateClassificationNode→ Classification Nodes - Create Or Update (REST 7.1)Mock for
CreateOrUpdateClassificationNodeis already generated atinternal/mocks/workitemtracking_client_mock.go:106-118. 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. Addcreate_test.gowith the following table-driven / behaviour tests, all usingt.Parallel()andgomock(requirefor preconditions,assertfor verifications):TestNewCmd_RegistersAsCreateLeaf— assertscmd.Name() == "create",cmd.Aliasescontainscandcr,cmd.Usestarts withcreate [ORGANIZATION/]PROJECT.TestNewCmd_NameFlagRequired— runscmd.SetArgs([]string{"Fabrikam"})+cmd.Execute(); asserts cobraMarkFlagRequirederror mentioningname.TestRunCreate_EmptyNameFlag— sets--name " "; assertsutil.FlagErrorfreturned with--namein message.TestRunCreate_RootLevelCreate— sets only--name Payments; stubswit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()); asserts capturedargs.Path == nil,args.StructureGroup == TreeStructureGroupValues.Areas,*args.Project == "Fabrikam",*args.PostedNode.Name == "Payments",args.PostedNode.Id == nil(no move semantics).TestRunCreate_NestedPathCreate— sets--name Refunds --path Payments; assertsargs.Path != niland equals"Payments"(URL-escaped segments).TestRunCreate_PathNormalizationStripsProjectAndArea— sets--path "Fabrikam/Area/Payments/Sub"; assertsargs.Path == "Payments/Sub".TestRunCreate_PathURLEscaping— sets--path "My Team/Sub Team"; assertsargs.Path == "My%20Team/Sub%20Team".TestRunCreate_StructureGroupIsAreas— asserts*args.StructureGroup == workitemtracking.TreeStructureGroupValues.Areas(literal"areas").TestRunCreate_PostedNodeHasName— assertsargs.PostedNode != nil,*args.PostedNode.Name == "X",args.PostedNode.Id == nil.TestRunCreate_ProjectScopeParsing— table-driven:[ORG/]PROJECT,PROJECT, invalid ("org/proj/extra"), empty.TestRunCreate_InvalidProjectScope— assertsutil.FlagErrorWrapreturned.TestRunCreate_ClientFactoryError— stubs factory to return error; asserts wrapped error.TestRunCreate_SDKError— stubs SDK to return error; asserts wrapped error.TestRunCreate_TableOutput— mocks return*WorkItemClassificationNode{Id, Name, Path, HasChildren}; parses stdout; asserts rowID=42, NAME=Payments, PATH=Fabrikam/Area/Payments, HAS CHILDREN=false.TestRunCreate_JSONOutput— sets--json; mocks return node; asserts JSON containsid,name,path,_links,hasChildren,url,identifier,structureType,attributes.TestRunCreate_OrganizationFromConfigDefault— when scopeArg isPROJECT(no org), assertsclientFact.WorkItemTracking(ctx, defaultOrg)is called with the configured default.Phase 2 — GREEN (minimal implementation). Strict reuse rules:
runCreate(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 parent-path normalization; the helper already URL-escapes segments.shared.NormalizeClassificationPathfor the response tablePathcolumn (REST returns backslashes; convert to forward slashes).wit.CreateOrUpdateClassificationNode(ctx.Context(), args)withargs.PostedNode.Name,args.Project,args.StructureGroup = &workitemtracking.TreeStructureGroupValues.Areas, and conditionallyargs.Path.ios.StartProgressIndicator()+defer ios.StopProgressIndicator(); callios.StopProgressIndicator()immediately before the table render (mirrorsinternal/cmd/repo/create/create.go).opts.exporter.Write(ios, res)passing the raw SDK*WorkItemClassificationNode; table viactx.Printer("table")withAddColumns/AddField/EndRow/Render.list.go:140-146).Target delta:
create.go≤ ~110 LOC,create_test.go≤ ~350 LOC (16 tests), parentproject.go+3 LOC,docs/boards_area_project_create.mdregenerated viamake docs. No changes tolist.go,delete.go,update.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/list/list.go— uses raw HTTP (NOT a template; SDK is preferred). Reusesshared.BuildClassificationPath(Decision 4) for the list endpoint URL construction.internal/cmd/boards/area/project/create/create.go(this issue) — primary deliverable.internal/cmd/boards/area/project/delete/delete.go(feat: Implementazdo boards area project deletecommand #206) — destructive-command sibling; mirror the SDK call shape and progress lifecycle.internal/cmd/boards/area/project/update/update.go(feat: Implementazdo boards area project updatecommand #208) — update sibling; mirror the JSON output contract and code structure.internal/cmd/boards/workitem/list/list_test.go:765-844—setupFakeDeps/stub*fixture; copy structure (Decision fixture §4).internal/cmd/boards/workitem/create/create.go(sibling feat: Implementazdo boards work-item createcommand #203) — progress lifecycle, JSON/table split, raw-SDK JSON output (mirror).internal/cmd/boards/shared/path.go—BuildClassificationPath+NormalizeClassificationPath(reuse, do not reimplement).internal/mocks/workitemtracking_client_mock.go:106-118— mock forCreateOrUpdateClassificationNode(already generated, do not regenerate).internal/azdo/factory.go—ClientFactory().WorkItemTracking(...)accessor (reuse).References