Skip to content

feat: Implement azdo boards iteration project update command #209

Description

@tmeckel

Sub-issue of #142. Sibling of #205 (iteration project create), #207 (iteration project delete), and #144 (iteration project list). Hardened spec — do not re-derive decisions. Mirrors #208 (area project update) with iteration-specific additions for dates and attributes.

Command Description

Update an iteration (sprint) in a project's iteration tree. v1 supports rename, start/finish date changes, and arbitrary attribute changes. Move (changing the parent path) is out of scope for v1.

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 or existing>, attributes: <merged>} to apply the update.

The SDK's URL is the same POST /_apis/wit/classificationnodes/Iterations/{nodePath}?api-version=7.1 used by create; only the body shape differs (contains id).

For v1 the structureGroup is hardcoded to Iterations (lowercase literal "iterations"). The scope name passed to BuildClassificationPath is "Iteration" (singular), matching the list/create/delete convention.

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 optional. When set, new name. When unset, existing name is preserved (otherwise REST would set name to empty). The body must always carry the current name.
4 --start-date and --finish-date are first-class flags (string), parsed strictly as time.RFC3339 (or YYYY-MM-DD → midnight UTC). Stored in PostedNode.Attributes as RFC 3339 strings to match the REST sample exactly. Iteration dates are the canonical reason this command exists.
5 finishDate < startDate is rejected with util.FlagErrorf when both flags are set. Catches a class of user errors before the network call.
6 --attributes key=value is a repeatable flag for arbitrary attributes. Merged into PostedNode.Attributes after start/finish dates; --start-date / --finish-date win on key conflict. Escape hatch; covers fields like custom Budget or Goal.
7 Pre-flight: at least one of --name, --start-date, --finish-date, --attributes must be set; otherwise util.FlagErrorf("at least one of --name, --start-date, --finish-date, or --attributes is required"). Prevents accidental no-op updates.
8 Attribute merge precedence on the body: existing (from Get) < --attributes key=v < --start-date / --finish-date (highest). Date flags are the authoritative source for date keys.
9 No --structure-group flag. Hardcoded to Iterations. Same as create/delete.
10 No --id flag. The id is fetched internally via GetClassificationNode. Saves the user from manual lookup.
11 No confirmation prompt, no --yes. Update is non-destructive (the node persists; only the name/dates/attributes change). Matches az boards work-item update.
12 No --reclassify-id. That's a delete-only concept. Scope discipline.
13 Aliases: u, up per AGENTS.md. Standard.
14 Path normalization via shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path) (singular). One helper, one source of truth.
15 JSON output: raw *WorkItemClassificationNode via opts.exporter.Write(ios, res). No view struct. Symmetric with create.
16 Table output: columns ID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN (single row, 6 columns). Mirrors create; surfaces freshly-set dates.
17 No new SDK client, no new helper, no new package. Inline buildUpdateAttributes / parseStrictDate / formatAttributeDate (≈30 LOC total). Mandate: minimal code.
18 Mocks for GetClassificationNode (:370-381) and CreateOrUpdateClassificationNode (:106-118) are already generated. Do not regenerate. Verified.
19 Defensive: if GetClassificationNode returns a node with Id == nil, return util.FlagErrorf("existing iteration 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.
20 Debug log: organization, project, path, fetched id, new name, attributeCount. Match create pattern.
21 parseStrictDate rejects the relaxed operators and "today" keyword that list's parseFlexibleDate accepts. Create/update surface does not get the list's date-filter semantics.
22 parseStrictDate and formatAttributeDate are duplicated between this issue and #205. Do NOT promote to a shared helper in v1 (8 + 6 lines). Mandate: minimal code; matches create sibling.

Command Signature

azdo boards iteration project update [ORGANIZATION/]PROJECT --path  [--name ] [--start-date D] [--finish-date D] [--attributes k=v ...]
  • 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 and CreateOrUpdateClassificationNodeArgs.Path URL-escaped; &lt;Project&gt;/&lt;Iteration&gt; segments stripped
--name (optional) CreateOrUpdateClassificationNodeArgs.PostedNode.Name When set: new name. When unset: existing name preserved.
--start-date (optional) PostedNode.Attributes["startDate"] (RFC 3339 string) Merged; wins on conflict with --attributes startDate
--finish-date (optional) PostedNode.Attributes["finishDate"] (RFC 3339 string) Merged; wins on conflict with --attributes finishDate; must be &gt;= startDate when both flags are set
--attributes (repeatable key=value) PostedNode.Attributes[key] Merged with existing; --start-date / --finish-date win on conflict
--json (optional) (no SDK mapping) Emit raw *WorkItemClassificationNode as JSON
--jq, --template (optional) (no SDK mapping) Filter / format JSON output

Pre-flight: at least one of --name, --start-date, --finish-date, --attributes must be set

If the user provides only --path (or only invalid/empty values for the other flags), the command is a no-op and should return:

util.FlagErrorf("at least one of --name, --start-date, --finish-date, or --attributes is required")

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 15). This is the updated node (server response), not the fetched one. Date strings live inside attributes.

Table default columns: ID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN (Decision 16). When neither date flag is set, the date columns render empty (not omitted).

Command Wiring

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

Code Skeleton (canonical, copy verbatim)

§1. updateOptions struct

type updateOptions struct {
    scopeArg   string
    path       string   // --path (node's own path)
    name       string   // --name (optional new name)
    startDate  string   // --start-date (RFC 3339 or YYYY-MM-DD)
    finishDate string   // --finish-date (RFC 3339 or YYYY-MM-DD)
    attributes []string // --attributes key=value (repeatable)
    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 ",
        Short: "Update an iteration in a project.",
        Long: heredoc.Doc(`
            Update an iteration (sprint) in a project. v1 supports renaming the node,
            changing start/finish dates, and setting arbitrary attributes. The path of
            the node cannot be changed in v1. At least one of --name, --start-date,
            --finish-date, or --attributes must be supplied.
        `),
        Example: heredoc.Doc(`
            # Rename an iteration
            azdo boards iteration project update Fabrikam --path "Sprint 1" --name "Sprint 1b"

            # Reschedule a sprint
            azdo boards iteration project update Fabrikam --path "Sprint 1" \
                --start-date 2025-01-06 --finish-date 2025-01-19

            # Add or change a custom attribute, keeping the existing dates
            azdo boards iteration project update Fabrikam --path "Sprint 1" \
                --attributes goal="Ship login"

            # Combine: rename + reschedule + set a custom attribute
            azdo boards iteration project update Fabrikam --path "Sprint 1" \
                --name "Sprint 1b" --start-date 2025-01-06 --finish-date 2025-01-19 \
                --attributes goal="Ship login"

            # Emit JSON
            azdo boards iteration project update Fabrikam --path "Sprint 1" --name "Sprint 1b" --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 iteration to update (under /Iteration, leading /Iteration stripped).")
    cmd.Flags().StringVar(&opts.name, "name", "", "New name for the iteration (omit to keep the existing name).")
    cmd.Flags().StringVar(&opts.startDate, "start-date", "", "New start date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes startDate.")
    cmd.Flags().StringVar(&opts.finishDate, "finish-date", "", "New finish date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes finishDate. Must be on or after start-date when both are set.")
    cmd.Flags().StringSliceVar(&opts.attributes, "attributes", nil, "Custom attribute in key=value form. Repeatable. Existing attributes not mentioned are preserved.")
    _ = cmd.MarkFlagRequired("path")
    util.AddJSONFlags(cmd, &opts.exporter, []string{
        "id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links",
    })

    return cmd
}

(Only --path is MarkFlagRequired. The other flags are individually optional but collectively required — see Pre-flight.)

§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, "Iteration", opts.path)
    if err != nil {
        return util.FlagErrorf("invalid --path: %w", err)
    }
    if nodePath == "" {
        return util.FlagErrorf("--path must reference a child of /Iteration, not the iteration root")
    }

    // Pre-flight: at least one of --name, --start-date, --finish-date, --attributes must be set
    if strings.TrimSpace(opts.name) == "" &&
        strings.TrimSpace(opts.startDate) == "" &&
        strings.TrimSpace(opts.finishDate) == "" &&
        len(opts.attributes) == 0 {
        return util.FlagErrorf("at least one of --name, --start-date, --finish-date, or --attributes is required")
    }

    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 and existing attributes.
    getArgs := workitemtracking.GetClassificationNodeArgs{
        Project:        types.ToPtr(scope.Project),
        StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations),
        Path:           types.ToPtr(nodePath),
    }

    zap.L().Debug("fetching iteration 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 iteration: %w", err)
    }
    if existing == nil || existing.Id == nil {
        return util.FlagErrorf("existing iteration has no ID; cannot update")
    }

    // Existing name fallback (when --name is unset, keep the fetched name)
    existingName := types.GetValue(existing.Name, "")
    newName := strings.TrimSpace(opts.name)
    if newName == "" {
        newName = existingName
    }

    // Build merged attributes: existing + --attributes + --start-date + --finish-date
    mergedAttrs, err := buildUpdateAttributes(existing.Attributes, opts.startDate, opts.finishDate, opts.attributes)
    if err != nil {
        return err
    }

    // 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.Iterations),
        Path:           types.ToPtr(nodePath),
        PostedNode: &workitemtracking.WorkItemClassificationNode{
            Id:   existing.Id,
            Name: types.ToPtr(newName),
        },
    }
    if len(mergedAttrs) > 0 {
        updateArgs.PostedNode.Attributes = &mergedAttrs
    }

    zap.L().Debug("updating iteration",
        zap.String("organization", scope.Organization),
        zap.String("project", scope.Project),
        zap.String("path", nodePath),
        zap.Int("id", *existing.Id),
        zap.String("newName", newName),
        zap.Int("attributeCount", len(mergedAttrs)),
    )

    res, err := wit.CreateOrUpdateClassificationNode(ctx.Context(), updateArgs)
    if err != nil {
        return fmt.Errorf("failed to update iteration: %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", "START DATE", "FINISH DATE", "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, "")))
    tp.AddField(formatAttributeDate(res.Attributes, "startDate"))
    tp.AddField(formatAttributeDate(res.Attributes, "finishDate"))
    if types.GetValue(res.HasChildren, false) {
        tp.AddField("true")
    } else {
        tp.AddField("false")
    }
    tp.EndRow()
    return tp.Render()
}

// buildUpdateAttributes assembles the Attributes map for an update.
// Order:
//  1. Start with the existing attributes (from GetClassificationNode).
//  2. Apply --attributes key=value pairs (overrides existing on key).
//  3. Apply --start-date / --finish-date (highest priority).
//  4. Validate finishDate &gt;= startDate when both are present.
func buildUpdateAttributes(existing *map[string]any, startDate, finishDate string, attrs []string) (map[string]any, error) {
    result := make(map[string]any)
    if existing != nil {
        for k, v := range *existing {
            result[k] = v
        }
    }

    for _, kv := range attrs {
        idx := strings.Index(kv, "=")
        if idx <= 0 {
            return nil, util.FlagErrorf("invalid --attributes %q: expected key=value", kv)
        }
        key := strings.TrimSpace(kv[:idx])
        if key == "" {
            return nil, util.FlagErrorf("invalid --attributes %q: empty key", kv)
        }
        result[key] = kv[idx+1:]
    }

    if raw := strings.TrimSpace(startDate); raw != "" {
        t, err := parseStrictDate(raw)
        if err != nil {
            return nil, util.FlagErrorf("invalid --start-date: %w", err)
        }
        result["startDate"] = t.UTC().Format(time.RFC3339)
    }
    if raw := strings.TrimSpace(finishDate); raw != "" {
        t, err := parseStrictDate(raw)
        if err != nil {
            return nil, util.FlagErrorf("invalid --finish-date: %w", err)
        }
        result["finishDate"] = t.UTC().Format(time.RFC3339)
    }

    if start, ok := result["startDate"]; ok {
        if finish, ok := result["finishDate"]; ok {
            s, _ := time.Parse(time.RFC3339, start.(string))
            f, _ := time.Parse(time.RFC3339, finish.(string))
            if f.Before(s) {
                return nil, util.FlagErrorf("--finish-date must be on or after --start-date")
            }
        }
    }

    return result, nil
}

// parseStrictDate accepts RFC 3339 (with T) or YYYY-MM-DD (midnight UTC).
// Rejects the relaxed operators and "today" keyword that list's parseFlexibleDate accepts.
func parseStrictDate(raw string) (time.Time, error) {
    if strings.Contains(raw, "T") {
        return time.Parse(time.RFC3339, raw)
    }
    return time.Parse("2006-01-02", raw)
}

func formatAttributeDate(attrs *map[string]any, key string) string {
    if attrs == nil {
        return ""
    }
    raw, ok := (*attrs)[key]
    if !ok || raw == nil {
        return ""
    }
    if s, ok := raw.(string); ok {
        return s
    }
    return fmt.Sprintf("%v", raw)
}

(Note: parseStrictDate and formatAttributeDate are duplicated between this issue and #205. They are 8 lines and 6 lines respectively. Do NOT promote to a shared helper in v1; matches the create mandate. If a third caller emerges, refactor.)

§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. Other flags must NOT be marked required.
  • TestRunUpdate_EmptyPathFlag — sets --path " "; asserts util.FlagErrorf with --path must not be empty.
  • TestRunUpdate_RootNode_Rejected — sets --path "" and --path "Fabrikam/Iteration"; asserts util.FlagErrorf because nodePath is empty.
  • TestRunUpdate_DefaultScope — sets --path "Sprint 1" --name "Sprint 1b"; stubs GetClassificationNode to return *WorkItemClassificationNode{Id: 42, Name: "Sprint 1", Path: "Fabrikam/Iteration/Sprint 1"}; stubs CreateOrUpdateClassificationNode; asserts both SDK calls used *StructureGroup == TreeStructureGroupValues.Iterations, *Project == "Fabrikam", *Path == "Sprint%201"; the update body's *Id == 42 and *Name == "Sprint 1b".
  • TestRunUpdate_NestedPath — sets --path "Release 2025/Sprint 1" --name X; asserts args.Path == "Release%202025/Sprint%201" for both SDK calls.
  • TestRunUpdate_PathNormalizationStripsProjectAndIteration — sets --path "Fabrikam/Iteration/Release 2025/Sprint 1" --name X; asserts args.Path == "Release%202025/Sprint%201".
  • TestRunUpdate_PathURLEscaping — sets --path "My Sprint/Sub Sprint" --name X; asserts args.Path == "My%20Sprint/Sub%20Sprint".
  • TestRunUpdate_StructureGroupIsIterations — asserts *args.StructureGroup == workitemtracking.TreeStructureGroupValues.Iterations (literal "iterations") 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 == "Sprint 1b".
  • TestRunUpdate_NamePreservedWhenUnset — sets only --start-date 2025-01-06; asserts the update body's *Name == existing.Name (preserved).
  • TestRunUpdate_NoFlags_ReturnsError — only --path; asserts util.FlagErrorf with at least one of --name, --start-date, --finish-date, or --attributes is required. Neither SDK call is made.
  • TestRunUpdate_StartDateOnly — sets --start-date 2025-01-06; fetched node has finishDate=2025-01-19; asserts attributes["startDate"] == "2025-01-06T00:00:00Z", attributes["finishDate"] == "2025-01-19T00:00:00Z" (preserved).
  • TestRunUpdate_FinishDateOnly — symmetric: only --finish-date, fetched start preserved.
  • TestRunUpdate_BothDates_RFC3339 — both set with T; both present in body as RFC 3339 strings.
  • TestRunUpdate_DateFlags_InvalidFormat--start-date yesterday; asserts util.FlagErrorf with invalid --start-date.
  • TestRunUpdate_DateFlags_FinishBeforeStart--start-date 2025-01-19 --finish-date 2025-01-06; asserts util.FlagErrorf with --finish-date must be on or after --start-date.
  • TestRunUpdate_AttributesOnly — sets --attributes goal=Ship; fetched node has startDate=2025-01-06, finishDate=2025-01-19; asserts attributes["goal"] == "Ship", attributes["startDate"] and attributes["finishDate"] preserved.
  • TestRunUpdate_AttributesMerge_StartDateFlagWins — both --start-date 2025-01-06 and --attributes startDate=2024-12-01; asserts attributes["startDate"] == "2025-01-06T00:00:00Z".
  • TestRunUpdate_AttributesMerge_FinishDateFlagWins — symmetric for finishDate.
  • TestRunUpdate_AttributesFlag_InvalidFormat--attributes "=value" and --attributes "novalue"; asserts util.FlagErrorf.
  • TestRunUpdate_AllFlags — name + start + finish + 2 attributes all set; asserts all in body.
  • TestRunUpdate_ExistingAttributesPreserved — fetched node has custom team=Alpha; user sets goal=Ship; asserts team AND goal in body.
  • TestRunUpdate_MissingIdInFetchedNode — stubs GetClassificationNode to return *WorkItemClassificationNode{Id: nil}; asserts util.FlagErrorf with existing iteration 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_AllColumns — mocks return updated node with attributes; parses stdout; asserts row has 6 columns ID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN.
  • 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 beyond the inline buildUpdateAttributes / parseStrictDate / formatAttributeDate in §3. Do not promote parseFlexibleDate from list.go; it has different semantics.
  • 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, "Iteration", 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, attributeCount.

Target delta: update.go ≤ ~200 LOC, update_test.go ≤ ~600 LOC (30 tests), parent project.go +3 LOC, docs/boards_iteration_project_update.md regenerated via make docs. No changes to list.go, create.go, delete.go, or iteration.go.

Tooling and Verification Checklist

  • gofmt / gofumpt on touched files
  • go test ./internal/cmd/boards/iteration/...
  • go test ./...
  • make lint
  • make docs

Reference Existing Patterns

  • #208 (area update) — the primary mirror; same SDK flow, same JSON/table split, same defensive ID check.
  • internal/cmd/boards/iteration/project/create/create.go (sibling feat: Implement azdo boards iteration project create command #205) — for stylistic consistency on iteration wording; copy the inline buildAttributes / parseStrictDate / formatAttributeDate shape.
  • internal/cmd/boards/iteration/project/delete/delete.go (sibling feat: Implement azdo boards iteration project delete command #207) — 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) — closest mirror; same JSON output contract, same two-step SDK flow.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture.
  • internal/cmd/boards/shared/path.goBuildClassificationPath + NormalizeClassificationPath (reuse, do not reimplement).
  • internal/mocks/workitemtracking_client_mock.go:370-381 and :106-118 — mocks for GetClassificationNode and CreateOrUpdateClassificationNode (both already generated, do not regenerate).
  • internal/azdo/factory.goClientFactory().WorkItemTracking(...) accessor (reuse).

References

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions