Skip to content

feat: Implement azdo boards area project update command #208

Description

@tmeckel

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:

  1. GetClassificationNode to fetch the current node (extract Id and current Attributes).
  2. 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.

Flags (mapped to SDK/REST)

Flag Maps to Notes
--path (required) GetClassificationNodeArgs.Path (URL) and CreateOrUpdateClassificationNodeArgs.Path (URL) Same path for both calls; URL-escaped; <Project>/<Area> segments stripped
--name (required) CreateOrUpdateClassificationNodeArgs.PostedNode.Name (body) New name; trimmed; non-empty required
--json (optional) (no SDK mapping) Emit raw *WorkItemClassificationNode as JSON
--jq, --template (optional) (no SDK mapping) Filter / format JSON output

JSON Output Contract

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

Pass the raw *WorkItemClassificationNode returned 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

  • Package path: internal/cmd/boards/area/project/update
  • Files:
    • update.goNewCmd(ctx util.CmdContext) *cobra.Command + updateOptions + runUpdate
    • update_test.go — table-driven gomock tests
  • Update internal/cmd/boards/area/project/project.go:
    import (
        updatecmd "github.com/tmeckel/azdo-cli/internal/cmd/boards/area/project/update"
    )
    cmd.AddCommand(updatecmd.NewCmd(ctx))
  • Existing higher-level parents must already remain wired: boardsareaprojectupdate.

Code Skeleton (canonical, copy verbatim)

§1. updateOptions struct

type updateOptions struct {
    scopeArg string
    path     string // --path (node's own path)
    name     string // --name (new name)
    exporter util.Exporter
}

§2. NewCmd shape (no surprises)

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

    return cmd
}

§3. runUpdate skeleton

func runUpdate(ctx util.CmdContext, opts *updateOptions) 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, "Area", opts.path)
    if err != nil {
        return util.FlagErrorf("invalid --path: %w", err)
    }
    if nodePath == "" {
        return util.FlagErrorf("--path must reference a child of /Area, not the area root")
    }

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

    wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization)
    if err != nil {
        return fmt.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)
    if err != nil {
        return fmt.Errorf("failed to fetch area path: %w", err)
    }
    if existing == nil || existing.Id == nil {
        return util.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)
    if err != nil {
        return fmt.Errorf("failed to update 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 fakeUpdateDeps struct {
    cmd        *mocks.MockCmdContext
    clientFact *mocks.MockClientFactory
    wit        *mocks.MockWorkItemTrackingClient
    stdout     *bytes.Buffer
}

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

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

    deps := &fakeUpdateDeps{
        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.

Both mocks are already generated. 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 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.
  • TestNewCmd_PathFlagRequired — runs cmd.SetArgs([]string{"Fabrikam", "--name", "X"}) + cmd.Execute(); asserts cobra MarkFlagRequired error mentioning path.
  • TestNewCmd_NameFlagRequired — runs cmd.SetArgs([]string{"Fabrikam", "--path", "Payments"}) + cmd.Execute(); asserts cobra MarkFlagRequired error mentioning name.
  • TestRunUpdate_EmptyPathFlag — sets --path " "; asserts util.FlagErrorf with --path must not be empty (or root-rejection equivalent).
  • TestRunUpdate_RootNode_Rejected — sets --path "" and --path "Fabrikam/Area"; asserts util.FlagErrorf because nodePath is empty.
  • TestRunUpdate_EmptyNameFlag — sets --name " "; asserts util.FlagErrorf with --name must not be empty.
  • TestRunUpdate_DefaultScope — sets --path Payments --name Billing; stubs GetClassificationNode to return *WorkItemClassificationNode{Id: 42, Name: "Payments", Path: "Fabrikam/Area/Payments"}; stubs CreateOrUpdateClassificationNode; asserts both SDK calls used *StructureGroup == TreeStructureGroupValues.Areas, *Project == "Fabrikam", *Path == "Payments"; the update body's *Id == 42 and *Name == "Billing".
  • TestRunUpdate_NestedPath — sets --path "Payments/Sub" --name "Sub2"; asserts args.Path == "Payments/Sub" for both SDK calls.
  • TestRunUpdate_PathNormalizationStripsProjectAndArea — sets --path "Fabrikam/Area/Payments/Sub" --name X; asserts args.Path == "Payments/Sub".
  • TestRunUpdate_PathURLEscaping — sets --path "My Team/Sub Team" --name X; asserts args.Path == "My%20Team/Sub%20Team".
  • 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_ProjectScopeParsing — table-driven: [ORG/]PROJECT, PROJECT, invalid ("org/proj/extra"), empty.
  • TestRunUpdate_InvalidProjectScope — asserts util.FlagErrorWrap returned.
  • TestRunUpdate_TableOutput — mocks return updated *WorkItemClassificationNode{Id: 42, Name: "Billing", Path: "Fabrikam/Area/Payments", HasChildren: false}; parses stdout; asserts row ID=42, NAME=Billing, PATH=Fabrikam/Area/Payments, HAS CHILDREN=false.
  • TestRunUpdate_JSONOutput — sets --json; mocks return updated node; asserts JSON contains id, name, path, _links, hasChildren, url, identifier, structureType, attributes.
  • 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 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 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.
  • internal/cmd/boards/area/project/delete/delete.go (sibling feat: Implement azdo boards area project delete command #206) — destructive-command sibling; mirror the SDK call shape and progress lifecycle.
  • 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:370-381 — mock for GetClassificationNode (already generated, do not regenerate).
  • 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