Skip to content

feat: Implement azdo boards area project create command #204

Description

@tmeckel

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.1
Content-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. Symmetric with #143, #206, #208.
9 Table output: columns ID, NAME, PATH, HAS CHILDREN (single row). Mirrors list table minus Level/ParentPath.
10 No confirmation prompt. Create is reversible via area project delete (#206). Matches az boards work-item create.
11 No pre-existence check. Defer to REST 4xx. Saves one round-trip; error is already clear.
12 No new SDK client, no new helper, no new package. Only the new create package + 3 LOC in project.go to wire it. Mandate: minimal code.
13 Mock for CreateOrUpdateClassificationNode is already generated at internal/mocks/workitemtracking_client_mock.go:106-118. Do not regenerate. Verified.
14 --name rejects whitespace-only via strings.TrimSpace(opts.name) == "" check, not via cobra MarkFlagRequired alone. cobra's required-flag check is empty-string-permissive.
15 No --depth or --include-ids. Create returns one node; those flags belong to list. Scope discipline.

Command Signature

azdo boards area project create [ORGANIZATION/]PROJECT
  • Aliases: c, cr
  • 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
--name (required) WorkItemClassificationNode.Name Trimmed; non-empty required
--path (optional) CreateOrUpdateClassificationNodeArgs.Path (URL segment, parent) Empty → project root

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter, []string{
    "id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links",
})

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).

Command Wiring

  • Package path: internal/cmd/boards/area/project/create
  • Files:
    • create.goNewCmd(ctx util.CmdContext) *cobra.Command + createOptions + runCreate
    • create_test.go — table-driven gomock tests
  • Update internal/cmd/boards/area/project/project.go:
    import (
        createcmd "github.com/tmeckel/azdo-cli/internal/cmd/boards/area/project/create"
    )
    cmd.AddCommand(createcmd.NewCmd(ctx))
  • Existing higher-level parents must already remain wired: boardsareaprojectcreate.

Code Skeleton (canonical, copy verbatim)

§1. createOptions struct

type createOptions struct {
    scopeArg string
    name     string // --name
    path     string // --path (parent)
    exporter util.Exporter
}

§2. NewCmd shape (no surprises)

func NewCmd(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]
            return runCreate(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",
    })
    return cmd
}

§3. runCreate skeleton

func runCreate(ctx util.CmdContext, opts *createOptions) 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)
    }

    name := strings.TrimSpace(opts.name)
    if name == "" {
        return util.FlagErrorf("--name must not be empty")
    }

    parentPath, err := shared.BuildClassificationPath(scope.Project, true, "Area", opts.path)
    if err != nil {
        return util.FlagErrorf("invalid --path: %w", err)
    }

    args := workitemtracking.CreateOrUpdateClassificationNodeArgs{
        PostedNode: &workitemtracking.WorkItemClassificationNode{
            Name: types.ToPtr(name),
        },
        Project:        types.ToPtr(scope.Project),
        StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Areas),
    }
    if parentPath != "" {
        args.Path = types.ToPtr(parentPath)
    }

    zap.L().Debug("creating area path",
        zap.String("organization", scope.Organization),
        zap.String("project", scope.Project),
        zap.String("name", name),
        zap.String("parentPath", parentPath),
    )

    wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization)
    if err != nil {
        return fmt.Errorf("failed to get classification client: %w", err)
    }

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

    ios.StopProgressIndicator()

    if opts.exporter != nil {
        return opts.exporter.Write(ios, res)
    }

    tp, err := ctx.Printer("table")
    if err != nil {
        return err
    }
    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, "")))
    if types.GetValue(res.HasChildren, false) {
        tp.AddField("true")
    } else {
        tp.AddField("false")
    }
    tp.EndRow()
    return tp.Render()
}

§4. Test fixture (copy from workitem/list/list_test.go:765-844)

type fakeCreateDeps struct {
    cmd        *mocks.MockCmdContext
    clientFact *mocks.MockClientFactory
    wit        *mocks.MockWorkItemTrackingClient
    stdout     *bytes.Buffer
}

func setupFakeDeps(t *testing.T, organization string) *fakeCreateDeps {
    t.Helper()
    ctrl := gomock.NewController(t)
    t.Cleanup(ctrl.Finish)

    io, _, out, _ := iostreams.Test()
    io.SetStdoutTTY(false)
    io.SetStderrTTY(false)

    deps := &fakeCreateDeps{
        cmd:        mocks.NewMockCmdContext(ctrl),
        clientFact: mocks.NewMockClientFactory(ctrl),
        wit:        mocks.NewMockWorkItemTrackingClient(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()

    tp, err := printer.NewTablePrinter(out, false, 200)
    require.NoError(t, err)
    deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes()

    return deps
}

API Surface

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

Mock for CreateOrUpdateClassificationNode is already generated at internal/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, 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.
  • TestNewCmd_NameFlagRequired — runs cmd.SetArgs([]string{"Fabrikam"}) + cmd.Execute(); asserts cobra MarkFlagRequired error mentioning name.
  • TestRunCreate_EmptyNameFlag — sets --name " "; asserts util.FlagErrorf returned with --name in message.
  • TestRunCreate_RootLevelCreate — sets only --name Payments; stubs wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()); asserts captured args.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; asserts args.Path != nil and equals "Payments" (URL-escaped segments).
  • TestRunCreate_PathNormalizationStripsProjectAndArea — sets --path "Fabrikam/Area/Payments/Sub"; asserts args.Path == "Payments/Sub".
  • TestRunCreate_PathURLEscaping — sets --path "My Team/Sub Team"; asserts args.Path == "My%20Team/Sub%20Team".
  • TestRunCreate_StructureGroupIsAreas — asserts *args.StructureGroup == workitemtracking.TreeStructureGroupValues.Areas (literal "areas").
  • TestRunCreate_PostedNodeHasName — asserts args.PostedNode != nil, *args.PostedNode.Name == "X", args.PostedNode.Id == nil.
  • TestRunCreate_ProjectScopeParsing — table-driven: [ORG/]PROJECT, PROJECT, invalid ("org/proj/extra"), empty.
  • TestRunCreate_InvalidProjectScope — asserts util.FlagErrorWrap returned.
  • 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 row ID=42, NAME=Payments, PATH=Fabrikam/Area/Payments, HAS CHILDREN=false.
  • TestRunCreate_JSONOutput — sets --json; mocks return node; asserts JSON contains id, name, path, _links, hasChildren, url, identifier, structureType, attributes.
  • 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 util.ParseProjectScope, util.AddJSONFlags, util.FlagErrorf/FlagErrorWrap, util.ExactArgs, types.GetValue, types.ToPtr, ctx.Printer("table"), ios.StartProgressIndicator/StopProgressIndicator, iostreams.Test as-is.
  • 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 conditionally args.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.
  • internal/cmd/boards/area/project/create/create.go (this issue) — primary deliverable.
  • internal/cmd/boards/area/project/delete/delete.go (feat: Implement azdo boards area project delete command #206) — destructive-command sibling; mirror the SDK call shape and progress lifecycle.
  • internal/cmd/boards/area/project/update/update.go (feat: Implement azdo boards area project update command #208) — update sibling; mirror the JSON output contract and code structure.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture; copy structure (Decision fixture §4).
  • internal/cmd/boards/workitem/create/create.go (sibling feat: Implement azdo boards work-item create command #203) — progress lifecycle, JSON/table split, raw-SDK JSON output (mirror).
  • internal/cmd/boards/shared/path.goBuildClassificationPath + NormalizeClassificationPath (reuse, do not reimplement).
  • internal/mocks/workitemtracking_client_mock.go:106-118 — mock for CreateOrUpdateClassificationNode (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