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 #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:
GetClassificationNode to fetch the current node (extract Id and current Attributes).
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.Attributesafter 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).
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).
typeupdateOptionsstruct {
scopeArgstringpathstring// --path (node's own path)namestring// --name (optional new name)startDatestring// --start-date (RFC 3339 or YYYY-MM-DD)finishDatestring// --finish-date (RFC 3339 or YYYY-MM-DD)attributes []string// --attributes key=value (repeatable)exporter util.Exporter
}
§2. NewCmd shape (no surprises)
funcNewCmd(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]
returnrunUpdate(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",
})
returncmd
}
(Only --path is MarkFlagRequired. The other flags are individually optional but collectively required — see Pre-flight.)
§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, "Iteration", opts.path)
iferr!=nil {
returnutil.FlagErrorf("invalid --path: %w", err)
}
ifnodePath=="" {
returnutil.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 setifstrings.TrimSpace(opts.name) ==""&&strings.TrimSpace(opts.startDate) ==""&&strings.TrimSpace(opts.finishDate) ==""&&len(opts.attributes) ==0 {
returnutil.FlagErrorf("at least one of --name, --start-date, --finish-date, or --attributes is required")
}
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 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)
iferr!=nil {
returnfmt.Errorf("failed to fetch iteration: %w", err)
}
ifexisting==nil||existing.Id==nil {
returnutil.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)
ifnewName=="" {
newName=existingName
}
// Build merged attributes: existing + --attributes + --start-date + --finish-datemergedAttrs, err:=buildUpdateAttributes(existing.Attributes, opts.startDate, opts.finishDate, opts.attributes)
iferr!=nil {
returnerr
}
// 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),
},
}
iflen(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)
iferr!=nil {
returnfmt.Errorf("failed to update iteration: %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", "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"))
iftypes.GetValue(res.HasChildren, false) {
tp.AddField("true")
} else {
tp.AddField("false")
}
tp.EndRow()
returntp.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 >= startDate when both are present.funcbuildUpdateAttributes(existing*map[string]any, startDate, finishDatestring, attrs []string) (map[string]any, error) {
result:=make(map[string]any)
ifexisting!=nil {
fork, v:=range*existing {
result[k] =v
}
}
for_, kv:=rangeattrs {
idx:=strings.Index(kv, "=")
ifidx<=0 {
returnnil, util.FlagErrorf("invalid --attributes %q: expected key=value", kv)
}
key:=strings.TrimSpace(kv[:idx])
ifkey=="" {
returnnil, util.FlagErrorf("invalid --attributes %q: empty key", kv)
}
result[key] =kv[idx+1:]
}
ifraw:=strings.TrimSpace(startDate); raw!="" {
t, err:=parseStrictDate(raw)
iferr!=nil {
returnnil, util.FlagErrorf("invalid --start-date: %w", err)
}
result["startDate"] =t.UTC().Format(time.RFC3339)
}
ifraw:=strings.TrimSpace(finishDate); raw!="" {
t, err:=parseStrictDate(raw)
iferr!=nil {
returnnil, util.FlagErrorf("invalid --finish-date: %w", err)
}
result["finishDate"] =t.UTC().Format(time.RFC3339)
}
ifstart, ok:=result["startDate"]; ok {
iffinish, ok:=result["finishDate"]; ok {
s, _:=time.Parse(time.RFC3339, start.(string))
f, _:=time.Parse(time.RFC3339, finish.(string))
iff.Before(s) {
returnnil, util.FlagErrorf("--finish-date must be on or after --start-date")
}
}
}
returnresult, 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.funcparseStrictDate(rawstring) (time.Time, error) {
ifstrings.Contains(raw, "T") {
returntime.Parse(time.RFC3339, raw)
}
returntime.Parse("2006-01-02", raw)
}
funcformatAttributeDate(attrs*map[string]any, keystring) string {
ifattrs==nil {
return""
}
raw, ok:= (*attrs)[key]
if!ok||raw==nil {
return""
}
ifs, ok:=raw.(string); ok {
returns
}
returnfmt.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)
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_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_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_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 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/shared/path.go — BuildClassificationPath + 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).
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:
GetClassificationNodeto fetch the current node (extractIdand currentAttributes).CreateOrUpdateClassificationNodewith 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.1used by create; only the body shape differs (containsid).For v1 the structureGroup is hardcoded to
Iterations(lowercase literal"iterations"). The scope name passed toBuildClassificationPathis"Iteration"(singular), matching the list/create/delete convention.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 optional. When set, new name. When unset, existing name is preserved (otherwise REST would set name to empty).--start-dateand--finish-dateare first-class flags (string), parsed strictly astime.RFC3339(orYYYY-MM-DD→ midnight UTC). Stored inPostedNode.Attributesas RFC 3339 strings to match the REST sample exactly.finishDate < startDateis rejected withutil.FlagErrorfwhen both flags are set.--attributes key=valueis a repeatable flag for arbitrary attributes. Merged intoPostedNode.Attributesafter start/finish dates;--start-date/--finish-datewin on key conflict.BudgetorGoal.--name,--start-date,--finish-date,--attributesmust be set; otherwiseutil.FlagErrorf("at least one of --name, --start-date, --finish-date, or --attributes is required").--attributes key=v<--start-date/--finish-date(highest).--structure-groupflag. Hardcoded toIterations.--idflag. The id is fetched internally viaGetClassificationNode.--yes. Update is non-destructive (the node persists; only the name/dates/attributes change).az boards work-item update.--reclassify-id. That's a delete-only concept.u,upper AGENTS.md.shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path)(singular).*WorkItemClassificationNodeviaopts.exporter.Write(ios, res). No view struct.ID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN(single row, 6 columns).buildUpdateAttributes/parseStrictDate/formatAttributeDate(≈30 LOC total).GetClassificationNode(:370-381) andCreateOrUpdateClassificationNode(:106-118) are already generated. Do not regenerate.GetClassificationNodereturns a node withId == nil, returnutil.FlagErrorf("existing iteration has no ID; cannot update"). The REST API treats a body withoutidas a create.parseStrictDaterejects the relaxed operators and"today"keyword that list'sparseFlexibleDateaccepts.parseStrictDateandformatAttributeDateare duplicated between this issue and #205. Do NOT promote to a shared helper in v1 (8 + 6 lines).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.PathandCreateOrUpdateClassificationNodeArgs.Path<Project>/<Iteration>segments stripped--name(optional)CreateOrUpdateClassificationNodeArgs.PostedNode.Name--start-date(optional)PostedNode.Attributes["startDate"](RFC 3339 string)--attributes startDate--finish-date(optional)PostedNode.Attributes["finishDate"](RFC 3339 string)--attributes finishDate; must be>= startDatewhen both flags are set--attributes(repeatablekey=value)PostedNode.Attributes[key]--start-date/--finish-datewin on conflict--json(optional)*WorkItemClassificationNodeas JSON--jq,--template(optional)Pre-flight: at least one of
--name,--start-date,--finish-date,--attributesmust be setIf the user provides only
--path(or only invalid/empty values for the other flags), the command is a no-op and should return:JSON Output Contract
Pass the raw
*WorkItemClassificationNodereturned by the SDK (Decision 15). This is the updated node (server response), not the fetched one. Date strings live insideattributes.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
internal/cmd/boards/iteration/project/updateupdate.go—NewCmd(ctx util.CmdContext) *cobra.Command+updateOptions+runUpdate+ inline helpersupdate_test.go— table-driven gomock testsinternal/cmd/boards/iteration/project/project.go:boards→iteration→project→update.Code Skeleton (canonical, copy verbatim)
§1.
updateOptionsstruct§2.
NewCmdshape (no surprises)(Only
--pathisMarkFlagRequired. The other flags are individually optional but collectively required — see Pre-flight.)§3.
runUpdateskeleton(Note:
parseStrictDateandformatAttributeDateare 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)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)workitemtracking.TreeStructureGroupValues.Iterations="iterations"(lowercase; atvendor/.../workitemtracking/models.go:967)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. Other flags must NOT be marked required.TestRunUpdate_EmptyPathFlag— sets--path " "; assertsutil.FlagErrorfwith--path must not be empty.TestRunUpdate_RootNode_Rejected— sets--path ""and--path "Fabrikam/Iteration"; assertsutil.FlagErrorfbecausenodePathis empty.TestRunUpdate_DefaultScope— sets--path "Sprint 1" --name "Sprint 1b"; stubsGetClassificationNodeto return*WorkItemClassificationNode{Id: 42, Name: "Sprint 1", Path: "Fabrikam/Iteration/Sprint 1"}; stubsCreateOrUpdateClassificationNode; asserts both SDK calls used*StructureGroup == TreeStructureGroupValues.Iterations,*Project == "Fabrikam",*Path == "Sprint%201"; the update body's*Id == 42and*Name == "Sprint 1b".TestRunUpdate_NestedPath— sets--path "Release 2025/Sprint 1" --name X; assertsargs.Path == "Release%202025/Sprint%201"for both SDK calls.TestRunUpdate_PathNormalizationStripsProjectAndIteration— sets--path "Fabrikam/Iteration/Release 2025/Sprint 1" --name X; assertsargs.Path == "Release%202025/Sprint%201".TestRunUpdate_PathURLEscaping— sets--path "My Sprint/Sub Sprint" --name X; assertsargs.Path == "My%20Sprint/Sub%20Sprint".TestRunUpdate_StructureGroupIsIterations— asserts*args.StructureGroup == workitemtracking.TreeStructureGroupValues.Iterations(literal"iterations") 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 == "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; assertsutil.FlagErrorfwithat 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 hasfinishDate=2025-01-19; assertsattributes["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 withT; both present in body as RFC 3339 strings.TestRunUpdate_DateFlags_InvalidFormat—--start-date yesterday; assertsutil.FlagErrorfwithinvalid --start-date.TestRunUpdate_DateFlags_FinishBeforeStart—--start-date 2025-01-19 --finish-date 2025-01-06; assertsutil.FlagErrorfwith--finish-date must be on or after --start-date.TestRunUpdate_AttributesOnly— sets--attributes goal=Ship; fetched node hasstartDate=2025-01-06, finishDate=2025-01-19; assertsattributes["goal"] == "Ship",attributes["startDate"]andattributes["finishDate"]preserved.TestRunUpdate_AttributesMerge_StartDateFlagWins— both--start-date 2025-01-06and--attributes startDate=2024-12-01; assertsattributes["startDate"] == "2025-01-06T00:00:00Z".TestRunUpdate_AttributesMerge_FinishDateFlagWins— symmetric for finishDate.TestRunUpdate_AttributesFlag_InvalidFormat—--attributes "=value"and--attributes "novalue"; assertsutil.FlagErrorf.TestRunUpdate_AllFlags— name + start + finish + 2 attributes all set; asserts all in body.TestRunUpdate_ExistingAttributesPreserved— fetched node has customteam=Alpha; user setsgoal=Ship; assertsteamANDgoalin body.TestRunUpdate_MissingIdInFetchedNode— stubsGetClassificationNodeto return*WorkItemClassificationNode{Id: nil}; assertsutil.FlagErrorfwithexisting iteration 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_AllColumns— mocks return updated node with attributes; parses stdout; asserts row has 6 columnsID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN.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:
buildUpdateAttributes/parseStrictDate/formatAttributeDatein §3. Do not promoteparseFlexibleDatefrom list.go; it has different semantics.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, "Iteration", 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≤ ~200 LOC,update_test.go≤ ~600 LOC (30 tests), parentproject.go+3 LOC,docs/boards_iteration_project_update.mdregenerated viamake docs. No changes tolist.go,create.go,delete.go, oriteration.go.Tooling and Verification Checklist
gofmt/gofumpton touched filesgo test ./internal/cmd/boards/iteration/...go test ./...make lintmake docsReference 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: Implementazdo boards iteration project createcommand #205) — for stylistic consistency on iteration wording; copy the inlinebuildAttributes/parseStrictDate/formatAttributeDateshape.internal/cmd/boards/iteration/project/delete/delete.go(sibling feat: Implementazdo boards iteration project deletecommand #207) — 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) — closest mirror; same JSON output contract, same two-step SDK flow.internal/cmd/boards/workitem/list/list_test.go:765-844—setupFakeDeps/stub*fixture.internal/cmd/boards/shared/path.go—BuildClassificationPath+NormalizeClassificationPath(reuse, do not reimplement).internal/mocks/workitemtracking_client_mock.go:370-381and:106-118— mocks forGetClassificationNodeandCreateOrUpdateClassificationNode(both already generated, do not regenerate).internal/azdo/factory.go—ClientFactory().WorkItemTracking(...)accessor (reuse).References