Skip to content

feat: Implement azdo boards work-item update command #270

@tmeckel

Description

@tmeckel

Sub-issue of #138. Hardened spec — do not re-derive decisions. Sibling of #136 (work-item list), #203 (work-item create), #238 (work-item show), #269 (work-item delete).

Command Description

Update one or more fields of an existing Azure Boards work item by ID. Mirrors az boards work-item update. The command builds a JSON Patch document from the supplied flags and sends it to the server.

The REST surface is Work Items - Update (REST 7.1):

PATCH https://dev.azure.com/{organization}/_apis/wit/workitems/{id}?api-version=7.1
Content-Type: application/json-patch+json

Body is a JSON Patch document (array of {op, path, value} ops). Response is the full updated WorkItem (id, rev, fields, _links, url, optional relations, commentVersionRef).

System.Description is an HTML field — Markdown source is accepted and rendered on the web form. The same description input modes used by #203 (create) are supported: inline (--description), file (--description-file, repeatable, - for stdin), and editor (--description-editor). The shared helper internal/cmd/boards/workitem/shared/description.go (introduced in #203) handles all three sources. Mirrors the AzDO Extension's update_work_item (azure-devops/azext_devops/dev/boards/work_item.py:106-170) and the AzDO MCP Server's update_work_item tool (microsoft/azure-devops-mcp/src/tools/work-items.ts:432-481).

Locked Decisions

# Decision Rationale
1 Org-scoped target. Use: "update [ORGANIZATION/]ID". The work item ID is unique within the organization. Matches az boards work-item update (no project positional) and internal/cmd/boards/workitem/show.
2 Aliases: []string{"u"}. Per AGENTS.md update command convention.
3 cobra.ExactArgs(1). One positional target: the work item ID.
4 Parse the single positional with util.ParseTargetWithDefaultOrganization(ctx, args[0]). scope.Targets[0] is the ID string. Existing helper handles 1- and 2-segment forms. Symmetric with #269 (delete).
5 No --project flag. PATCH URL is org-scoped (PATCH /_apis/wit/workitems/{id}); the Python never passes a project to client.update_work_item. The Go SDK's UpdateWorkItemArgs.Project is *string (optional) — pass nil. Matches az boards work-item update exactly.
6 ID must parse as a positive integer. If strconv.Atoi(scope.Targets[0]) fails or returns <= 0, return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0]) and do not call the SDK. Defends against typo'd IDs without a wasted round-trip. Symmetric with #269 Decision 14.
7 No mutex check on flags. The Python does not enforce a "at least one flag" rule — it builds whatever patch ops it can and sends them. The server rejects an empty patch with a 4xx. We surface that via util.FlagErrorWrap. Matches az ergonomics. Avoids duplicating the server's validation.
8 --assigned-to passes the user-provided value as a plain string into /fields/System.AssignedTo. Same string-pass-through as #203 Decision 1. No identity resolution on the client. Azure DevOps resolves the string server-side.
9 --fields parses as Ref.Name=value, split on the first = only. Reject if no = is present. Same as #203 Decision 2.
10 No tag-joining helper. The Python's update_work_item does not expose a --tag flag (only create does). Do not add one. Matches az ergonomics — az boards work-item update has no --tag.
11 Canonical op order in the patch doc is fixed (see Code Skeleton §1). Tests assert this order. Predictability for tests and for downstream debugging. Mirrors #203 Decision 4.
12 No new helpers beyond the shared description helper (Decision 23). Inline the patch-doc append using the webapi.JsonPatchOperation pattern from security/group/update/update.go:103-124. Mandate: minimal code.
13 Identity helper reuse: do not import resolveAssignedToFilter from list.go. Use a plain string. Mirrors #203 Decision 1.
14 JSON output passes the raw SDK WorkItem to opts.exporter.Write. Do not introduce a view struct. Mirrors #203 Decision 7.
15 No confirmation prompt. Update is reversible via further updates. Mirrors #203 Decision 8.
16 Stderr warning when --bypass-rules or --suppress-notifications is set. Mirrors #203 Decision 8.
17 No pre-check that the work item exists. The REST 404 is surfaced via util.FlagErrorWrap. Mirrors #203 Decision 9.
18 Mock AddComment negative assertion is mandatory. The TestRunUpdate_DiscussionTriggersAddComment test must include a literal wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Times(0) when --discussion is not set. Mirrors #203 Decision 10.
19 Default output: table row with columns ID, TYPE, STATE, TITLE, ASSIGNED TO, AREA, ITERATION — same shape as #203 (create). Lets the user verify the new state, title, and assignment. Mirrors #203 table output. The data is useful; one-line message would hide it.
20 Uses vendored workitemtracking.Client.UpdateWorkItem. Vendor verified at vendor/.../v7/workitemtracking/client.go:3602 (impl) and :205 (interface).
21 No new mock needed. internal/mocks/workitemtracking_client_mock.go:1358-1371 already mocks UpdateWorkItem. AddComment mock already exists.
22 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.
23 The description accepts three input modes (Decisions 24-26 cover the rules): inline via --description, file via --description-file (repeatable, - reads from stdin), or interactive editor via --description-editor. Reuse internal/cmd/boards/workitem/shared/description.go (introduced in #203) unchanged — this command does not introduce a new helper. Symmetric with #203. The shared helper is the single source of truth for description resolution.
24 --description-file <path> is repeatable. Multiple invocations concatenate with \n. The special token - reads from os.Stdin. Mirrors #203 Decision 12. Lets the user reassemble descriptions from existing Markdown files.
25 --description-editor opens $VISUAL (preferred) or $EDITOR, falling back to vi on POSIX / notepad on Windows. A .md temp file is pre-populated with a header comment. Lines starting with # are stripped on read-back. Empty result → error. Mirrors #203 Decision 13. The shared helper handles all of this; the update command just exposes the flag.
26 Description source precedence: editor > file > inline (most explicit wins). When the user supplies multiple sources, a warning to stderr names the source that was selected. The command does not error on multiple sources — it picks the highest-priority one and warns. Mirrors #203 Decision 14. Both commands behave identically.
27 No Markdown↔HTML conversion on the client. The azdo CLI passes the description text through to the server's /fields/System.Description op unchanged. The Azure DevOps server renders Markdown. Mirrors #203 Decision 16. The AzDO MCP Server's encodeFormattedValue is only relevant for fields that explicitly take a format parameter.

Command Signature

var updateCmd = &cobra.Command{
    Use:     "update [ORGANIZATION/]ID",
    Aliases: []string{"u"},
    Short:   "Update a work item.",
    Long: heredoc.Doc(`
        Update one or more fields of an existing work item. The work item is
        identified by ID. Build a JSON Patch document from the supplied flags
        and send it to the server. At least one field flag is required.
    `),
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runUpdate(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Maps to Notes
--title /fields/System.Title
--description /fields/System.Description HTML/Markdown. Lower-priority than --description-file and --description-editor (Decision 26).
--description-file (repeatable) /fields/System.Description Read description from <path>. Repeatable; multiple files are concatenated with \n. The special token - reads from stdin. Higher-priority than --description (Decision 26).
--description-editor /fields/System.Description Open $VISUAL/$EDITOR (fallback vi/notepad) with a .md temp file. Pre-populated with a header comment. Lines starting with # are stripped. Highest-priority (Decision 26).
--assigned-to /fields/System.AssignedTo String pass-through (Decision 8)
--state /fields/System.State New state name
--area /fields/System.AreaPath
--iteration /fields/System.IterationPath
--reason /fields/System.Reason
--fields (repeatable Ref.Name=value) raw /fields/Ref.Name Split on first = (Decision 9)
--discussion comment via wit.AddComment Side-effect — appends a comment to the work item, not a field patch (mirrors #203)
--bypass-rules UpdateWorkItemArgs.BypassRules
--suppress-notifications UpdateWorkItemArgs.SuppressNotifications
--validate-only UpdateWorkItemArgs.ValidateOnly
--expand (enum) UpdateWorkItemArgs.Expand None/Relations/Fields/Links/All
--open open returned URL in default browser Mirrors az
--organization *Path.Organization Default org from config

Also add util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"}) (every JSON-tagged field of the updated *workitemtracking.WorkItem).

Code Skeleton (canonical, copy verbatim)

§1. updateOptions struct and canonical patch-doc order

type updateOptions struct {
    targetArg string

    title             string // --title
    description       string // --description (inline)
    descriptionFiles  []string // --description-file (repeatable; "-" reads stdin)
    descriptionEditor bool     // --description-editor (bool flag)
    assignedTo        string
    state             string
    area              string
    iteration         string
    reason            string
    customFields      []fieldKV // --fields Ref.Name=value
    discussion        string

    bypassRules           bool
    suppressNotifications bool
    validateOnly          bool
    expand                string
    openInBrowser         bool

    exporter util.Exporter
}

type fieldKV struct{ ref, value string }

The patch doc must append ops in this exact order. Tests assert order:

  1. /fields/System.Title
  2. /fields/System.Description (only if ResolveDescription returns non-empty)
  3. /fields/System.AssignedTo
  4. /fields/System.State
  5. /fields/System.AreaPath
  6. /fields/System.IterationPath
  7. /fields/System.Reason
  8. raw /fields/ ops from --fields (in user-given order)
  9. (No /relations/- — Python update does not expose --link.)
  10. (No /fields/System.Tags — Decision 10.)

§2. Patch-doc construction (4-line pattern, copy from security/group/update/update.go:103-124)

add := webapi.OperationValues.Add
doc := []webapi.JsonPatchOperation{}
patch := func(path string, value any) {
    p := path
    doc = append(doc, webapi.JsonPatchOperation{Op: &add, Path: &p, Value: value})
}
// ... append in the order above ...

§3. runUpdate signature

func runUpdate(ctx util.CmdContext, opts *updateOptions, targetArg string) error

§4. NewCmd shape (no surprises)

func NewCmd(ctx util.CmdContext) *cobra.Command {
    opts := &updateOptions{}
    cmd := &cobra.Command{
        Use:     "update [ORGANIZATION/]ID",
        Short:   "Update a work item.",
        Aliases: []string{"u"},
        Args:    util.ExactArgs(1, "work item ID required"),
        Example: heredoc.Doc(`# update a work item's title
            azdo boards work-item update 1234 --title "New title"
            # update description from a Markdown file
            azdo boards work-item update 1234 --description-file ./updated-repro.md
            # edit description in $EDITOR
            azdo boards work-item update 1234 --description-editor`),
        RunE: func(cmd *cobra.Command, args []string) error {
            return runUpdate(ctx, opts, args[0])
        },
    }
    // --title, --description, --description-file, --description-editor,
    // --assigned-to, --state, --area, --iteration, --reason, --expand, --discussion : StringVar (description-file is StringSliceVar; description-editor is BoolVar)
    // --fields : StringSliceVar
    // --bypass-rules, --suppress-notifications, --validate-only, --open : BoolVar
    util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"})
    return cmd
}

§5. runUpdate skeleton

func runUpdate(cmdCtx util.CmdContext, opts *updateOptions, targetArg string) error {
    ios, err := cmdCtx.IOStreams()
    if err != nil { return err }
    ios.StartProgressIndicator()
    defer ios.StopProgressIndicator()

    scope, err := util.ParseTargetWithDefaultOrganization(cmdCtx, targetArg)
    if err != nil { return util.FlagErrorWrap(err) }

    id, err := strconv.Atoi(scope.Targets[0])
    if err != nil || id <= 0 {
        return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0])
    }

    description, err := shared.ResolveDescription(ios, shared.DescriptionOptions{
        Inline: opts.description,
        Files:  opts.descriptionFiles,
        Editor: opts.descriptionEditor,
    })
    if err != nil { return util.FlagErrorWrap(err) }

    doc := buildPatchDocument(opts, description) // see §2; description is "" -> skip
    args := workitemtracking.UpdateWorkItemArgs{
        Document:              &doc,
        Id:                    &id,
        ValidateOnly:          types.ToPtr(opts.validateOnly),
        BypassRules:           types.ToPtr(opts.bypassRules),
        SuppressNotifications: types.ToPtr(opts.suppressNotifications),
    }
    if opts.expand != "" {
        e := workitemtracking.WorkItemExpand(opts.expand)
        args.Expand = &e
    }

    wit, err := cmdCtx.ClientFactory().WorkItemTracking(cmdCtx.Context(), scope.Organization)
    if err != nil { return err }

    res, err := wit.UpdateWorkItem(cmdCtx.Context(), args)
    if err != nil { return err }

    if opts.discussion != "" {
        // Append discussion as a comment, mirroring #203 (create).
        // Project is required by AddComment — use the work item's project
        // (looked up from res.Fields["System.TeamProject"]) if available,
        // else the empty string (server fallback).
        project := types.GetValue(fieldString(res.Fields, "System.TeamProject"), "")
        if _, err := wit.AddComment(cmdCtx.Context(), workitemtracking.AddCommentArgs{
            Project:    types.ToPtr(project),
            WorkItemId: res.Id,
            Comment:    &workitemtracking.Comment{Text: types.ToPtr(opts.discussion)},
        }); err != nil { return err }
    }

    if opts.bypassRules || opts.suppressNotifications {
        fmt.Fprintf(ios.ErrOut, "warning: --bypass-rules/--suppress-notifications bypass work item type rules and notifications\n")
    }

    ios.StopProgressIndicator() // before user-visible output

    if opts.exporter != nil {
        return opts.exporter.Write(ios, res) // raw SDK WorkItem, no view struct
    }
    tp, err := cmdCtx.Printer("list")
    if err != nil { return err }
    tp.AddColumns("ID", "TYPE", "STATE", "TITLE", "ASSIGNED TO", "AREA", "ITERATION")
    tp.AddField(strconv.Itoa(types.GetValue(res.Id, 0)))
    tp.AddField(fieldString(res.Fields, "System.WorkItemType"))
    tp.AddField(fieldString(res.Fields, "System.State"))
    tp.AddField(fieldString(res.Fields, "System.Title"))
    tp.AddField(fieldIdentityDisplay(res.Fields, "System.AssignedTo"))
    tp.AddField(fieldString(res.Fields, "System.AreaPath"))
    tp.AddField(fieldString(res.Fields, "System.IterationPath"))
    tp.EndRow()
    return tp.Render()
}

fieldString and fieldIdentityDisplay are already in internal/cmd/boards/workitem/shared/fields.go (promoted by #203). Reuse from there.

JSON Output Contract

Pass the raw *workitemtracking.WorkItem returned by UpdateWorkItem to opts.exporter.Write. No view struct.

JSON fields exposed (matching SDK struct tags): id, rev, fields, url, _links, relations, commentVersionRef.

Table Output Contract

Use ctx.Printer("list") with the same 7 columns and AddField sequence as #203. The table renders a single row containing the updated work item's data.

Command Wiring

  1. Create internal/cmd/boards/workitem/update/update.go with package update, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/update" to internal/cmd/boards/workitem/workitem.go.
  3. Register in workitem.go with cmd.AddCommand(update.NewCmd(ctx)) and extend the group's Example block.
  4. Reuse internal/cmd/boards/workitem/shared/fields.go (fieldString, fieldIdentityDisplay) promoted by feat: Implement azdo boards work-item create command #203.
  5. Reuse internal/cmd/boards/workitem/shared/description.go (introduced in feat: Implement azdo boards work-item create command #203) — shared.ResolveDescription, shared.ReadDescriptionFiles, shared.OpenEditor. No new shared file.

API Surface

Vendored SDK call (from vendor/.../v7/workitemtracking/client.go:3602):

args := workitemtracking.UpdateWorkItemArgs{
    Document: &doc,
    Id:       &id,
    // BypassRules, SuppressNotifications, ValidateOnly, Expand set conditionally
    // Project is intentionally nil (Decision 5)
}
res, err := wit.UpdateWorkItem(ctx, args)

AddComment call (only when --discussion is set):

project := types.GetValue(fieldString(res.Fields, "System.TeamProject"), "")
_, err := wit.AddComment(ctx, workitemtracking.AddCommentArgs{
    Project:    types.ToPtr(project),
    WorkItemId: res.Id,
    Comment:    &workitemtracking.Comment{Text: types.ToPtr(opts.discussion)},
})

The vendored SDK enforces args.Document != nil and args.Id != nil on UpdateWorkItem — will return ArgumentNilError if missing. No extra validation needed in our wrapper beyond the positive-integer check (Decision 6).

Reference Existing Patterns

  • internal/cmd/boards/workitem/create (feat: Implement azdo boards work-item create command #203) — primary reference. Mirrors the JSON Patch construction pattern, the raw *WorkItem JSON output, the shared/fields.go helpers, the progress indicator lifecycle, the webapi.JsonPatchOperation append pattern, and the AddComment discussion side-effect.
  • internal/cmd/boards/workitem/shared/description.go (introduced in feat: Implement azdo boards work-item create command #203) — single source of truth for description resolution. Reused unchanged by this command. Covers precedence, file reading (UTF-8 validation, 1 MB size cap, binary detection), editor invocation ($VISUAL/$EDITOR fallback), and header-comment stripping.
  • internal/cmd/security/group/update/update.go:103-124 — secondary reference for the 4-line patch-doc append pattern.
  • internal/cmd/boards/workitem/list/list.go — reference for the table output and field helpers.
  • internal/cmd/boards/workitem/show — sibling for the org-scoped ParseTargetWithDefaultOrganization usage.

Reference implementations (for UX parity only — azdo is the implementation target):

  • AzDO Extension azure-devops/azext_devops/dev/boards/work_item.py:106-170 - update_work_item(id, title, description, ...) confirms the description parameter is a plain string. The AzDO Extension does not have editor or file-import support; this is new in azdo.
  • AzDO MCP Server microsoft/azure-devops-mcp/src/tools/work-items.ts:432-481 - update_work_item MCP tool. Confirms the server's System.Description field accepts Markdown directly; no client-side conversion is needed.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_update Inspect the command struct. Use == "update [ORGANIZATION/]ID", Aliases == ["u"], Args == cobra.ExactArgs(1).
Test_runUpdate_minimalTitle Only --title set. Mock UpdateWorkItem returns a WorkItem. Captured args.Document has exactly one add op on /fields/System.Title. Table renders with the new title.
Test_runUpdate_allOptionalFields_canonicalOrder Every optional flag set. Captured patch doc has exactly the 8 op positions in the order listed in Code Skeleton §1.
Test_runUpdate_customFields --fields "Foo.Bar=value" --fields "Baz.Qux=other". Two raw /fields/ ops appended at positions 8+ in user-given order.
Test_runUpdate_discussionTriggersAddComment --discussion set. wit.AddComment is called once with the discussion text and WorkItemId matching the response's Id.
Test_runUpdate_noDiscussion --discussion not set. Literal gomock form: wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Times(0).
Test_runUpdate_bypassRulesAndSuppressNotifications Both flags set. args.BypassRules and args.SuppressNotifications are &true. Stderr warning emitted.
Test_runUpdate_validateOnly --validate-only set. args.ValidateOnly is &true; response is still rendered.
Test_runUpdate_expand --expand=All set. args.Expand points to workitemtracking.WorkItemExpand("All").
Test_runUpdate_invalidID Positional is "abc". Returns util.FlagErrorf with "work item ID must be a positive integer; got \"abc\"". SDK is not called.
Test_runUpdate_zeroID Positional is "0". Returns util.FlagErrorf. SDK is not called.
Test_runUpdate_negativeID Positional is "-5". Returns util.FlagErrorf. SDK is not called.
Test_runUpdate_orgScopeOnly Positional is "1234" (no org). args.Id == ptr(1234), scope.Organization is the default.
Test_runUpdate_explicitOrg Positional is "myorg/1234". args.Id == ptr(1234), scope.Organization is "myorg".
Test_runUpdate_APIError Mock returns errors.New("boom"). Returns wrapped error including the ID.
Test_runUpdate_success_JSON --json flag set. Exporter receives the raw *WorkItem (assert res.Id == ptr(1234)).
Test_runUpdate_tableOutput Default output, mock returns a work item. Table has 1 row with the 7 columns populated from res.Fields.
Test_runUpdate_fieldsParseSplitOnFirstEquals --fields "Foo.Bar=key=value". Parses as ref="Foo.Bar", value="key=value".
Test_runUpdate_emptyPatchDoc No field flags set (only --bypass-rules for example). Captured args.Document is a non-nil pointer to an empty slice. Server returns 4xx; we surface via util.FlagErrorWrap.
Test_runUpdate_openBrowserFlag --open is a no-op in tests (DI not used). Capture-and-discard.
Test_runUpdate_missingID User runs azdo boards work-item update with no args. Cobra args validation returns the standard "accepts 1 arg(s)" error.

Description tests (new — Decisions 23-27):

  • Test_runUpdate_DescriptionFromInline - --description "text". Captured args.Document has /fields/System.Description op with value "text".
  • Test_runUpdate_DescriptionFromSingleFile - --description-file ./foo.md (test creates a temp file). Captured op has the file's content.
  • Test_runUpdate_DescriptionFromStdin - --description-file - with ios.In set via iostreams.System().SetIn(strings.NewReader("stdin content")). Captured op has "stdin content".
  • Test_runUpdate_DescriptionFromMultipleFiles_Concatenated - --description-file a.md --description-file b.md. Captured op has a + "\n" + b.
  • Test_runUpdate_DescriptionFileNotFound - --description-file /nonexistent. Returns util.FlagErrorf with the path. SDK is not called.
  • Test_runUpdate_DescriptionFileTooLarge - temp file > 1 MB. Returns util.FlagErrorf with "exceeds 1 MB".
  • Test_runUpdate_DescriptionFileBinary - temp file with null byte in first 8KB. Returns util.FlagErrorf with "appears to be binary".
  • Test_runUpdate_DescriptionFileNotUTF8 - temp file with invalid UTF-8 sequence. Returns util.FlagErrorf with "not valid UTF-8".
  • Test_runUpdate_DescriptionEditor - inject shared.execEditorCommand = fakeEditor("written by editor"). Captured op has "written by editor".
  • Test_runUpdate_DescriptionEditorStripsCommentLines - inject fakeEditor("# comment\n# also comment\n# my notes\nactual content\n"). Captured op has "actual content".
  • Test_runUpdate_DescriptionEditorEmptyAborts - inject fakeEditor(""). Returns util.FlagErrorf with "editor produced empty description".
  • Test_runUpdate_DescriptionEditorNonZeroExit - inject editor that returns exit code 1. Returns wrapped error.
  • Test_runUpdate_DescriptionPrecedenceEditorOverFile - both --description-editor and --description-file set. Editor wins; warning to stderr contains "takes precedence over --description-file".
  • Test_runUpdate_DescriptionPrecedenceEditorOverInline - both --description-editor and --description set. Editor wins; warning contains "takes precedence over --description".
  • Test_runUpdate_DescriptionPrecedenceFileOverInline - both --description-file and --description set. File wins; warning contains "takes precedence over --description".
  • Test_runUpdate_DescriptionAbsent_OmitsPatchOp - no description flags set. Captured args.Document has no /fields/System.Description op.

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