From 8fe90150e44631279add66c89a7dadef3f290856 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 21:01:17 +0000 Subject: [PATCH 1/5] feat(boards): add shared utilities for work item descriptions and fields Add reusable helpers to resolve descriptions from inline flags, files/stdin, or an interactive editor (with size/binary/utf8 validation and header stripping). Also provide field accessors that safely extract string values and identity display names from work item data maps. These utilities enable consistent description input and field formatting across azdo boards work-item commands. --- .../cmd/boards/workitem/shared/description.go | 154 ++++++++++++++++++ internal/cmd/boards/workitem/shared/fields.go | 48 ++++++ 2 files changed, 202 insertions(+) create mode 100644 internal/cmd/boards/workitem/shared/description.go create mode 100644 internal/cmd/boards/workitem/shared/fields.go diff --git a/internal/cmd/boards/workitem/shared/description.go b/internal/cmd/boards/workitem/shared/description.go new file mode 100644 index 00000000..cedc3002 --- /dev/null +++ b/internal/cmd/boards/workitem/shared/description.go @@ -0,0 +1,154 @@ +package shared + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "unicode/utf8" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" +) + +const ( + maxDescriptionBytes = 1024 * 1024 + binaryCheckBytes = 8 * 1024 +) + +// editorHeader pre-populates the editor temp file; lines starting with '#' +// are stripped when the description is read back. +const editorHeader = "# Enter the description for the work item below.\n# Lines starting with '#' are ignored.\n" + +// ExecEditorCommand runs the resolved editor command against the given file. +// It is a package-level variable so tests can replace it with a fake. +var ExecEditorCommand = func(command []string, file string) error { + cmd := exec.Command(command[0], append(command[1:], file)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// DescriptionOptions collects the three description input sources. +type DescriptionOptions struct { + Inline string // --description + Files []string // --description-file (repeatable; "-" reads stdin) + Editor bool // --description-editor + // EditorCommand is the editor resolved from configuration (AZDO_EDITOR / + // config "editor" key). It takes precedence over $VISUAL/$EDITOR. + EditorCommand string +} + +// ResolveDescription returns the description from the highest-priority source +// (editor > file > inline) and warns on stderr when a lower-priority source is +// ignored. Returns "" when no source is configured. +func ResolveDescription(ios *iostreams.IOStreams, opts DescriptionOptions) (string, error) { + switch { + case opts.Editor: + switch { + case len(opts.Files) > 0: + fmt.Fprintf(ios.ErrOut, "warning: --description-editor takes precedence over --description-file\n") + case opts.Inline != "": + fmt.Fprintf(ios.ErrOut, "warning: --description-editor takes precedence over --description\n") + } + return OpenEditor(opts.EditorCommand) + case len(opts.Files) > 0: + if opts.Inline != "" { + fmt.Fprintf(ios.ErrOut, "warning: --description-file takes precedence over --description\n") + } + return ReadDescriptionFiles(ios, opts.Files) + default: + return opts.Inline, nil + } +} + +// ReadDescriptionFiles concatenates the given files (in order) with "\n". +// The token "-" reads from stdin. Files are validated against a 1 MB size cap, +// binary content, and invalid UTF-8. +func ReadDescriptionFiles(ios *iostreams.IOStreams, files []string) (string, error) { + parts := make([]string, 0, len(files)) + for _, file := range files { + var data []byte + var err error + if file == "-" { + data, err = io.ReadAll(ios.In) + } else { + data, err = os.ReadFile(file) //nolint:gosec // path is user-supplied by design (--description-file) + } + if err != nil { + return "", util.FlagErrorf("failed to read description file %q: %v", file, err) + } + if len(data) > maxDescriptionBytes { + return "", util.FlagErrorf("description file %q exceeds 1 MB limit", file) + } + if bytes.IndexByte(data[:min(len(data), binaryCheckBytes)], 0) >= 0 { + return "", util.FlagErrorf("description file %q appears to be binary", file) + } + if !utf8.Valid(data) { + return "", util.FlagErrorf("description file %q is not valid UTF-8", file) + } + parts = append(parts, string(data)) + } + return strings.Join(parts, "\n"), nil +} + +// OpenEditor opens a .md temp file pre-populated with a header comment in the +// configured editor (config "editor" key / AZDO_EDITOR), falling back to +// $VISUAL, then $EDITOR, then vi/notepad. Lines starting with '#' are stripped +// on read-back; an empty result is an error. +func OpenEditor(editorCommand string) (string, error) { + editor := editorCommand + if editor == "" { + editor = os.Getenv("VISUAL") + } + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "vi" + } + } + + file, err := os.CreateTemp("", "azdo-description-*.md") + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer os.Remove(file.Name()) + + if _, err := file.WriteString(editorHeader); err != nil { + file.Close() + return "", fmt.Errorf("failed to write temporary file: %w", err) + } + if err := file.Close(); err != nil { + return "", fmt.Errorf("failed to close temporary file: %w", err) + } + + if err := ExecEditorCommand(strings.Fields(editor), file.Name()); err != nil { + return "", fmt.Errorf("failed to run editor %q: %w", editor, err) + } + + data, err := os.ReadFile(file.Name()) + if err != nil { + return "", fmt.Errorf("failed to read edited description: %w", err) + } + + var kept []string + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + kept = append(kept, line) + } + description := strings.TrimSpace(strings.Join(kept, "\n")) + if description == "" { + return "", util.FlagErrorf("editor produced empty description") + } + return description, nil +} diff --git a/internal/cmd/boards/workitem/shared/fields.go b/internal/cmd/boards/workitem/shared/fields.go new file mode 100644 index 00000000..693a676d --- /dev/null +++ b/internal/cmd/boards/workitem/shared/fields.go @@ -0,0 +1,48 @@ +package shared + +import "fmt" + +// FieldString returns the string representation of a work item field value. +// Missing or nil values render as the empty string. +func FieldString(fields map[string]any, key string) string { + if fields == nil { + return "" + } + v, ok := fields[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + default: + return fmt.Sprint(v) + } +} + +// FieldIdentityDisplay returns the display name of an identity field value. +// Plain strings are returned unchanged; identity maps prefer displayName over +// uniqueName. +func FieldIdentityDisplay(fields map[string]any, key string) string { + if fields == nil { + return "" + } + v, ok := fields[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case map[string]any: + if displayName, ok := t["displayName"].(string); ok { + return displayName + } + if uniqueName, ok := t["uniqueName"].(string); ok { + return uniqueName + } + return fmt.Sprint(v) + default: + return fmt.Sprint(v) + } +} From d257c76743ea3e4234059495e5ad3089dd56edd1 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 21:01:48 +0000 Subject: [PATCH 2/5] refactor(boards): use shared field utilities in workitem list --- internal/cmd/boards/workitem/list/list.go | 53 +++---------------- .../cmd/boards/workitem/list/list_test.go | 19 +++---- 2 files changed, 17 insertions(+), 55 deletions(-) diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index 9074cef7..ddfa3cbb 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -15,6 +15,7 @@ import ( "go.uber.org/zap" "github.com/tmeckel/azdo-cli/internal/azdo/extensions" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/shared" "github.com/tmeckel/azdo-cli/internal/cmd/util" "github.com/tmeckel/azdo-cli/internal/types" ) @@ -268,12 +269,12 @@ func renderWorkItemsTable(ctx util.CmdContext, workItems []workitemtracking.Work for _, wi := range workItems { fields := types.GetValue(wi.Fields, map[string]any{}) tp.AddField(strconv.Itoa(types.GetValue(wi.Id, 0))) - tp.AddField(fieldString(fields, "System.WorkItemType")) - tp.AddField(fieldString(fields, "System.State")) - tp.AddField(fieldString(fields, "System.Title")) - tp.AddField(fieldIdentityDisplay(fields, "System.AssignedTo")) - tp.AddField(fieldString(fields, "System.AreaPath")) - tp.AddField(fieldString(fields, "System.IterationPath")) + tp.AddField(shared.FieldString(fields, "System.WorkItemType")) + tp.AddField(shared.FieldString(fields, "System.State")) + tp.AddField(shared.FieldString(fields, "System.Title")) + tp.AddField(shared.FieldIdentityDisplay(fields, "System.AssignedTo")) + tp.AddField(shared.FieldString(fields, "System.AreaPath")) + tp.AddField(shared.FieldString(fields, "System.IterationPath")) tp.EndRow() } @@ -999,43 +1000,3 @@ func orderWorkItemsByIDs(items []workitemtracking.WorkItem, ids []int) []workite return ordered } - -func fieldString(fields map[string]any, key string) string { - if fields == nil { - return "" - } - v, ok := fields[key] - if !ok || v == nil { - return "" - } - switch t := v.(type) { - case string: - return t - default: - return fmt.Sprint(v) - } -} - -func fieldIdentityDisplay(fields map[string]any, key string) string { - if fields == nil { - return "" - } - v, ok := fields[key] - if !ok || v == nil { - return "" - } - switch t := v.(type) { - case string: - return t - case map[string]any: - if displayName, ok := t["displayName"].(string); ok { - return displayName - } - if uniqueName, ok := t["uniqueName"].(string); ok { - return uniqueName - } - return fmt.Sprint(v) - default: - return fmt.Sprint(v) - } -} diff --git a/internal/cmd/boards/workitem/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go index 94dfb101..a9784aff 100644 --- a/internal/cmd/boards/workitem/list/list_test.go +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/shared" "github.com/tmeckel/azdo-cli/internal/cmd/util" "github.com/tmeckel/azdo-cli/internal/iostreams" "github.com/tmeckel/azdo-cli/internal/mocks" @@ -611,10 +612,10 @@ func TestFieldString(t *testing.T) { "a": "hello", "b": 42, } - assert.Equal(t, "hello", fieldString(fields, "a")) - assert.Equal(t, "42", fieldString(fields, "b")) - assert.Equal(t, "", fieldString(fields, "missing")) - assert.Equal(t, "", fieldString(nil, "a")) + assert.Equal(t, "hello", shared.FieldString(fields, "a")) + assert.Equal(t, "42", shared.FieldString(fields, "b")) + assert.Equal(t, "", shared.FieldString(fields, "missing")) + assert.Equal(t, "", shared.FieldString(nil, "a")) } func TestFieldIdentityDisplay(t *testing.T) { @@ -625,11 +626,11 @@ func TestFieldIdentityDisplay(t *testing.T) { "b": map[string]any{"displayName": "Bob", "uniqueName": "bob@x.com"}, "c": map[string]any{"uniqueName": "carol@x.com"}, } - assert.Equal(t, "Alice", fieldIdentityDisplay(fields, "a")) - assert.Equal(t, "Bob", fieldIdentityDisplay(fields, "b")) - assert.Equal(t, "carol@x.com", fieldIdentityDisplay(fields, "c")) - assert.Equal(t, "", fieldIdentityDisplay(fields, "missing")) - assert.Equal(t, "", fieldIdentityDisplay(nil, "a")) + assert.Equal(t, "Alice", shared.FieldIdentityDisplay(fields, "a")) + assert.Equal(t, "Bob", shared.FieldIdentityDisplay(fields, "b")) + assert.Equal(t, "carol@x.com", shared.FieldIdentityDisplay(fields, "c")) + assert.Equal(t, "", shared.FieldIdentityDisplay(fields, "missing")) + assert.Equal(t, "", shared.FieldIdentityDisplay(nil, "a")) } func TestIdentityAccountOrDisplay(t *testing.T) { From 0b2ba08ca7309f13fd3fe8e6f9ff5bf06a34eff0 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 21:02:01 +0000 Subject: [PATCH 3/5] feat: Implement `azdo boards work-item update` command Fixes #270 --- internal/cmd/boards/workitem/update/update.go | 307 ++++++++++++++++++ internal/cmd/boards/workitem/workitem.go | 5 + 2 files changed, 312 insertions(+) create mode 100644 internal/cmd/boards/workitem/update/update.go diff --git a/internal/cmd/boards/workitem/update/update.go b/internal/cmd/boards/workitem/update/update.go new file mode 100644 index 00000000..820548ad --- /dev/null +++ b/internal/cmd/boards/workitem/update/update.go @@ -0,0 +1,307 @@ +package update + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/config" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type updateOptions struct { + targetArg string + + title string // --title + description string // --description (inline) + descriptionFiles []string // --description-file (repeatable; "-" reads stdin) + descriptionEditor bool // --description-editor + assignedTo string + state string + area string + iteration string + reason string + customFields []string // --fields Ref.Name=value (repeatable) + discussion string + + bypassRules bool + suppressNotifications bool + validateOnly bool + expand string + openInBrowser bool + + exporter util.Exporter +} + +type fieldKV struct{ ref, value string } + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &updateOptions{} + + cmd := &cobra.Command{ + Use: "update [ORG:]PROJECT/ID", + Short: "Update a work item.", + Aliases: []string{"u"}, + 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. + `), + Example: heredoc.Doc(` + # update a work item's title + azdo boards work-item update Fabrikam/1234 --title "New title" + + # update description from a Markdown file + azdo boards work-item update Fabrikam/1234 --description-file ./updated-repro.md + + # edit description in $EDITOR + azdo boards work-item update Fabrikam/1234 --description-editor + `), + Args: util.ExactArgs(1, "project/work item target required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runUpdate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.title, "title", "", "New title of the work item.") + cmd.Flags().StringVar(&opts.description, "description", "", "New description (Markdown). Lower priority than --description-file and --description-editor.") + cmd.Flags().StringSliceVar(&opts.descriptionFiles, "description-file", nil, "Read description from file (repeatable; \"-\" reads from stdin). Higher priority than --description.") + cmd.Flags().BoolVar(&opts.descriptionEditor, "description-editor", false, "Edit description in $VISUAL/$EDITOR. Highest priority description source.") + cmd.Flags().StringVar(&opts.assignedTo, "assigned-to", "", "Identity the work item is assigned to.") + cmd.Flags().StringVar(&opts.state, "state", "", "New state of the work item.") + cmd.Flags().StringVar(&opts.area, "area", "", "New area path of the work item.") + cmd.Flags().StringVar(&opts.iteration, "iteration", "", "New iteration path of the work item.") + cmd.Flags().StringVar(&opts.reason, "reason", "", "Reason for the change of state.") + cmd.Flags().StringSliceVar(&opts.customFields, "fields", nil, "Set a field by reference name (repeatable; Ref.Name=value).") + cmd.Flags().StringVar(&opts.discussion, "discussion", "", "Comment to add to the work item discussion.") + cmd.Flags().BoolVar(&opts.bypassRules, "bypass-rules", false, "Do not enforce the work item type rules on this update.") + cmd.Flags().BoolVar(&opts.suppressNotifications, "suppress-notifications", false, "Do not fire any notifications for this change.") + cmd.Flags().BoolVar(&opts.validateOnly, "validate-only", false, "Only validate the changes without saving the work item.") + cmd.Flags().StringVar(&opts.expand, "expand", "", "Expand parameters: None, Relations, Fields, Links, All.") + cmd.Flags().BoolVar(&opts.openInBrowser, "open", false, "Open the updated work item in the default browser.") + + util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"}) + + return cmd +} + +func runUpdate(cmdCtx util.CmdContext, opts *updateOptions) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(cmdCtx, opts.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]) + } + + zap.L().Debug( + "resolved work item update target", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("workItemId", id), + ) + + wit, err := cmdCtx.ClientFactory().WorkItemTracking(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create work item tracking client: %w", err) + } + + item, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{ + Id: &id, + Project: types.ToPtr(scope.Project), + Fields: types.ToPtr([]string{shared.TeamProjectField}), + }) + if err != nil { + return fmt.Errorf("failed to fetch work item %d: %w", id, err) + } + if !shared.BelongsToProject(item, scope.Project) { + return fmt.Errorf("work item %d does not belong to project %q", id, scope.Project) + } + + editorCommand := "" + if opts.descriptionEditor { + cfg, err := cmdCtx.Config() + if err != nil { + return err + } + editorCommand, err = config.DetermineEditor(cfg) + if err != nil { + return err + } + } + + description, err := shared.ResolveDescription(ios, shared.DescriptionOptions{ + Inline: opts.description, + Files: opts.descriptionFiles, + Editor: opts.descriptionEditor, + EditorCommand: editorCommand, + }) + if err != nil { + return util.FlagErrorWrap(err) + } + + customFields, err := parseCustomFields(opts.customFields) + if err != nil { + return err + } + + doc := buildPatchDocument(opts, description, customFields) + args := workitemtracking.UpdateWorkItemArgs{ + Project: types.ToPtr(scope.Project), + 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 + } + + res, err := wit.UpdateWorkItem(cmdCtx.Context(), args) + if err != nil { + return fmt.Errorf("failed to update work item %d: %w", id, err) + } + + if opts.discussion != "" { + fields := types.GetValue(res.Fields, map[string]any{}) + project := shared.FieldString(fields, shared.TeamProjectField) + if _, err := wit.AddComment(cmdCtx.Context(), workitemtracking.AddCommentArgs{ + Project: types.ToPtr(project), + WorkItemId: res.Id, + Request: &workitemtracking.CommentCreate{Text: types.ToPtr(opts.discussion)}, + }); err != nil { + return fmt.Errorf("failed to add discussion comment to work item %d: %w", id, 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() + + if opts.exporter != nil { + return opts.exporter.Write(ios, res) + } + + tp, err := cmdCtx.Printer("list") + if err != nil { + return err + } + tp.AddColumns("ID", "TYPE", "STATE", "TITLE", "ASSIGNED TO", "AREA", "ITERATION") + fields := types.GetValue(res.Fields, map[string]any{}) + tp.AddField(strconv.Itoa(types.GetValue(res.Id, 0))) + tp.AddField(shared.FieldString(fields, "System.WorkItemType")) + tp.AddField(shared.FieldString(fields, "System.State")) + tp.AddField(shared.FieldString(fields, "System.Title")) + tp.AddField(shared.FieldIdentityDisplay(fields, "System.AssignedTo")) + tp.AddField(shared.FieldString(fields, "System.AreaPath")) + tp.AddField(shared.FieldString(fields, "System.IterationPath")) + tp.EndRow() + if err := tp.Render(); err != nil { + return err + } + + if opts.openInBrowser { + if err := openURL(types.GetValue(res.Url, "")); err != nil { + return fmt.Errorf("failed to open work item in browser: %w", err) + } + } + return nil +} + +// buildPatchDocument appends ops in a fixed order: Title, Description, +// AssignedTo, State, AreaPath, IterationPath, Reason, then raw --fields ops in +// user-given order. Tests assert this order. +func buildPatchDocument(opts *updateOptions, description string, customFields []fieldKV) []webapi.JsonPatchOperation { + 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}) + } + + if opts.title != "" { + patch("/fields/System.Title", opts.title) + } + if description != "" { + patch("/fields/System.Description", description) + } + if opts.assignedTo != "" { + patch("/fields/System.AssignedTo", opts.assignedTo) + } + if opts.state != "" { + patch("/fields/System.State", opts.state) + } + if opts.area != "" { + patch("/fields/System.AreaPath", opts.area) + } + if opts.iteration != "" { + patch("/fields/System.IterationPath", opts.iteration) + } + if opts.reason != "" { + patch("/fields/System.Reason", opts.reason) + } + for _, f := range customFields { + patch("/fields/"+f.ref, f.value) + } + return doc +} + +// parseCustomFields splits each Ref.Name=value on the first "=" only. +func parseCustomFields(raw []string) ([]fieldKV, error) { + fields := make([]fieldKV, 0, len(raw)) + for _, r := range raw { + ref, value, ok := strings.Cut(r, "=") + if !ok { + return nil, util.FlagErrorf("--fields value %q must be in the form Ref.Name=value", r) + } + fields = append(fields, fieldKV{ref: ref, value: value}) + } + return fields, nil +} + +// openURL opens a URL in the default browser: $BROWSER if set, otherwise the +// platform opener (xdg-open/open/rundll32). Empty URLs are ignored. +func openURL(raw string) error { + if raw == "" { + return nil + } + if browser := os.Getenv("BROWSER"); browser != "" { + parts := strings.Fields(browser) + cmd := exec.Command(parts[0], append(parts[1:], raw)...) //nolint:gosec // BROWSER env is an explicit user-chosen command + cmd.Stderr = os.Stderr + return cmd.Run() + } + switch runtime.GOOS { + case "darwin": + return exec.Command("open", raw).Run() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", raw).Run() + default: + return exec.Command("xdg-open", raw).Run() + } +} diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go index 58eb82e9..e294ea44 100644 --- a/internal/cmd/boards/workitem/workitem.go +++ b/internal/cmd/boards/workitem/workitem.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/delete" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/list" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -17,12 +18,16 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { # List work items in a project azdo boards work-item list Fabrikam + # Update a work item's title + azdo boards work-item update Fabrikam/42 --title "New title" + # Delete a work item azdo boards work-item delete Fabrikam/42 --yes `), } cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(update.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) return cmd From a98a5bba7a3383744451b5894dbc27f699181c66 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 21:02:21 +0000 Subject: [PATCH 4/5] test(boards): add tests for work item update command --- .../cmd/boards/workitem/update/update_test.go | 951 ++++++++++++++++++ 1 file changed, 951 insertions(+) create mode 100644 internal/cmd/boards/workitem/update/update_test.go diff --git a/internal/cmd/boards/workitem/update/update_test.go b/internal/cmd/boards/workitem/update/update_test.go new file mode 100644 index 00000000..a5f432bd --- /dev/null +++ b/internal/cmd/boards/workitem/update/update_test.go @@ -0,0 +1,951 @@ +package update + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/shared" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + config *mocks.MockConfig + auth *mocks.MockAuthConfig + in *bytes.Buffer + stdout *bytes.Buffer + errOut *bytes.Buffer +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, in, out, errOut := iostreams.Test() + + deps := &dependencies{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + config: mocks.NewMockConfig(ctrl), + auth: mocks.NewMockAuthConfig(ctrl), + in: in, + stdout: out, + errOut: errOut, + } + + 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.cmd.EXPECT().Config().Return(deps.config, nil).AnyTimes() + deps.config.EXPECT().Authentication().Return(deps.auth).AnyTimes() + if organization != "" { + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, nil).AnyTimes() + } + + return deps +} + +func (d *dependencies) setupDefaultOrg(org string) { + d.auth.EXPECT().GetDefaultOrganization().Return(org, nil).AnyTimes() +} + +func (d *dependencies) setupEditor(editor string) { + d.config.EXPECT().Get([]string{"", "editor"}).Return(editor, nil).AnyTimes() +} + +func (d *dependencies) stubPreflight(t *testing.T, project string) { + d.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemArgs) (*workitemtracking.WorkItem, error) { + require.NotNil(t, args.Id) + require.NotNil(t, args.Project) + assert.Equal(t, project, *args.Project) + fields := map[string]interface{}{"System.TeamProject": project} + return &workitemtracking.WorkItem{Id: args.Id, Fields: &fields}, nil + }, + ) +} + +func (d *dependencies) stubUpdateWorkItem(t *testing.T, project string, extraFields ...map[string]any) *workitemtracking.UpdateWorkItemArgs { + var captured workitemtracking.UpdateWorkItemArgs + d.wit.EXPECT().UpdateWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.UpdateWorkItemArgs) (*workitemtracking.WorkItem, error) { + require.NotNil(t, args.Project) + assert.Equal(t, project, *args.Project) + captured = args + fields := map[string]interface{}{"System.TeamProject": project} + for _, extra := range extraFields { + for k, v := range extra { + fields[k] = v + } + } + return &workitemtracking.WorkItem{Id: args.Id, Fields: &fields}, nil + }, + ) + return &captured +} + +func updatedWorkItem(id int, fields map[string]any) *workitemtracking.WorkItem { + return &workitemtracking.WorkItem{Id: types.ToPtr(id), Fields: &fields} +} + +func patchPaths(doc *[]webapi.JsonPatchOperation) []string { + paths := make([]string, 0, len(*doc)) + for _, op := range *doc { + paths = append(paths, types.GetValue(op.Path, "")) + } + return paths +} + +func TestNewCmd_update(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "update [ORG:]PROJECT/ID", cmd.Use) + assert.Equal(t, []string{"u"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) + require.NoError(t, cmd.Args(cmd, []string{"Fabrikam/1234"})) + assert.Error(t, cmd.Args(cmd, []string{"Fabrikam/1234", "Extra"})) + assert.Error(t, cmd.Args(cmd, []string{})) + + f := cmd.Flags() + for _, name := range []string{ + "title", "description", "description-file", "description-editor", "assigned-to", + "state", "area", "iteration", "reason", "fields", "discussion", "bypass-rules", + "suppress-notifications", "validate-only", "expand", "open", "json", + } { + assert.NotNil(t, f.Lookup(name), "flag %q must exist", name) + } +} + +func TestNewCmd_missingTarget(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "project/work item target required") +} + +func Test_runUpdate_minimalTitle(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam", map[string]any{"System.Title": "New title"}) + + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", title: "New title"}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "add", string(*(*args.Document)[0].Op)) + assert.Equal(t, "/fields/System.Title", *(*args.Document)[0].Path) + assert.Equal(t, "New title", (*args.Document)[0].Value) + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) + assert.Contains(t, deps.stdout.String(), "New title") +} + +func Test_runUpdate_allOptionalFields_canonicalOrder(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + opts := &updateOptions{ + targetArg: "Fabrikam/1234", + title: "T", + description: "D", + assignedTo: "A", + state: "S", + area: "AR", + iteration: "I", + reason: "R", + customFields: []string{ + "Foo.Bar=value", + }, + } + err := runUpdate(deps.cmd, opts) + require.NoError(t, err) + + require.NotNil(t, args.Document) + assert.Equal(t, []string{ + "/fields/System.Title", + "/fields/System.Description", + "/fields/System.AssignedTo", + "/fields/System.State", + "/fields/System.AreaPath", + "/fields/System.IterationPath", + "/fields/System.Reason", + "/fields/Foo.Bar", + }, patchPaths(args.Document)) +} + +func Test_runUpdate_customFields(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + title: "T", + customFields: []string{"Foo.Bar=value", "Baz.Qux=other"}, + }) + require.NoError(t, err) + + require.NotNil(t, args.Document) + assert.Equal(t, []string{"/fields/System.Title", "/fields/Foo.Bar", "/fields/Baz.Qux"}, patchPaths(args.Document)) + require.Len(t, *args.Document, 3) + assert.Equal(t, "value", (*args.Document)[1].Value) + assert.Equal(t, "other", (*args.Document)[2].Value) +} + +func Test_runUpdate_fieldsParseSplitOnFirstEquals(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + customFields: []string{"Foo.Bar=key=value"}, + }) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "/fields/Foo.Bar", *(*args.Document)[0].Path) + assert.Equal(t, "key=value", (*args.Document)[0].Value) +} + +func Test_runUpdate_customFieldsMissingEquals(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + customFields: []string{"Foo.Bar"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--fields value \"Foo.Bar\" must be in the form Ref.Name=value") +} + +func Test_runUpdate_discussionTriggersAddComment(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + var captured workitemtracking.AddCommentArgs + deps.wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.AddCommentArgs) (*workitemtracking.Comment, error) { + captured = args + return &workitemtracking.Comment{}, nil + }, + ) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", discussion: "nice work"}) + require.NoError(t, err) + + require.NotNil(t, captured.Project) + assert.Equal(t, "Fabrikam", *captured.Project) + require.NotNil(t, captured.WorkItemId) + assert.Equal(t, 1234, *captured.WorkItemId) + require.NotNil(t, captured.Request) + require.NotNil(t, captured.Request.Text) + assert.Equal(t, "nice work", *captured.Request.Text) +} + +func Test_runUpdate_noDiscussion(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + deps.wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Times(0) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234"}) + require.NoError(t, err) +} + +func Test_runUpdate_bypassRulesAndSuppressNotifications(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + bypassRules: true, + suppressNotifications: true, + }) + require.NoError(t, err) + + require.NotNil(t, args.BypassRules) + assert.True(t, *args.BypassRules) + require.NotNil(t, args.SuppressNotifications) + assert.True(t, *args.SuppressNotifications) + assert.Contains(t, deps.errOut.String(), "warning: --bypass-rules/--suppress-notifications") +} + +func Test_runUpdate_validateOnly(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", validateOnly: true}) + require.NoError(t, err) + + require.NotNil(t, args.ValidateOnly) + assert.True(t, *args.ValidateOnly) +} + +func Test_runUpdate_expand(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", expand: "All"}) + require.NoError(t, err) + + require.NotNil(t, args.Expand) + assert.Equal(t, workitemtracking.WorkItemExpand("All"), *args.Expand) +} + +func Test_runUpdate_invalidID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target string + expected string + }{ + {name: "non numeric", target: "Fabrikam/abc", expected: `work item ID must be a positive integer; got "abc"`}, + {name: "zero", target: "Fabrikam/0", expected: `work item ID must be a positive integer; got "0"`}, + {name: "negative", target: "Fabrikam/-5", expected: `work item ID must be a positive integer; got "-5"`}, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: tc.target}) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expected) + }) + } +} + +func Test_runUpdate_projectScopeDefaultOrg(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234"}) + require.NoError(t, err) + + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) +} + +func Test_runUpdate_explicitOrganizationProject(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "myorg:Fabrikam/1234"}) + require.NoError(t, err) + + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) +} + +func Test_runUpdate_missingDefaultOrganization(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no organization specified") +} + +func Test_runUpdate_ProjectMismatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fields *map[string]interface{} + expected string + }{ + { + name: "different project", + fields: &map[string]interface{}{"System.TeamProject": "OtherProject"}, + expected: `work item 1234 does not belong to project "Fabrikam"`, + }, + { + name: "missing team project field", + fields: &map[string]interface{}{}, + expected: `work item 1234 does not belong to project "Fabrikam"`, + }, + { + name: "nil fields", + expected: `work item 1234 does not belong to project "Fabrikam"`, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).Return( + &workitemtracking.WorkItem{Fields: tc.fields}, nil, + ) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", title: "T"}) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expected) + }) + } +} + +func Test_runUpdate_PreflightError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch work item 1234: boom") +} + +func Test_runUpdate_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().UpdateWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", title: "T"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to update work item 1234: boom") +} + +func Test_runUpdate_clientError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("myorg") + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), "myorg").Return(nil, fmt.Errorf("no client")) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create work item tracking client: no client") +} + +func Test_runUpdate_emptyPatchDoc(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", bypassRules: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + assert.Empty(t, *args.Document) +} + +func Test_runUpdate_success_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().UpdateWorkItem(gomock.Any(), gomock.Any()).Return( + updatedWorkItem(1234, map[string]any{}), nil, + ) + + exporter := &captureExporter{} + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", title: "T", exporter: exporter}) + require.NoError(t, err) + + got, ok := exporter.data.(*workitemtracking.WorkItem) + require.True(t, ok, "exporter must receive the raw WorkItem") + require.NotNil(t, got.Id) + assert.Equal(t, 1234, *got.Id) +} + +func Test_runUpdate_tableOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.wit.EXPECT().UpdateWorkItem(gomock.Any(), gomock.Any()).Return( + updatedWorkItem(1234, map[string]any{ + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Title": "Fix the bug", + "System.AssignedTo": "Alice ", + "System.AreaPath": "Fabrikam\\Web", + "System.IterationPath": "Fabrikam\\Release 1\\Sprint 1", + }), nil, + ) + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", title: "T"}) + require.NoError(t, err) + + out := deps.stdout.String() + assert.Contains(t, out, "1234") + assert.Contains(t, out, "User Story") + assert.Contains(t, out, "Active") + assert.Contains(t, out, "Fix the bug") + assert.Contains(t, out, "Alice ") + assert.Contains(t, out, "Fabrikam\\Web") + assert.Contains(t, out, "Fabrikam\\Release 1\\Sprint 1") +} + +func Test_runUpdate_openBrowserFlag(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", openInBrowser: true}) + require.NoError(t, err) +} + +func Test_runUpdate_DescriptionFromInline(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", description: "text"}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "/fields/System.Description", *(*args.Document)[0].Path) + assert.Equal(t, "text", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionFromSingleFile(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "desc.md") + require.NoError(t, os.WriteFile(file, []byte("file content"), 0o600)) + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{file}}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "file content", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionFromStdin(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + deps.in.WriteString("stdin content") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{"-"}}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "stdin content", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionFromMultipleFiles_Concatenated(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + fileA := filepath.Join(dir, "a.md") + fileB := filepath.Join(dir, "b.md") + require.NoError(t, os.WriteFile(fileA, []byte("alpha"), 0o600)) + require.NoError(t, os.WriteFile(fileB, []byte("beta"), 0o600)) + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{fileA, fileB}}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "alpha\nbeta", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionFileNotFound(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{"/nonexistent"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "/nonexistent") +} + +func Test_runUpdate_DescriptionFileTooLarge(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "big.md") + require.NoError(t, os.WriteFile(file, bytes.Repeat([]byte("a"), 1024*1024+1), 0o600)) + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{file}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds 1 MB") +} + +func Test_runUpdate_DescriptionFileBinary(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "bin.md") + require.NoError(t, os.WriteFile(file, []byte("abc\x00def"), 0o600)) + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{file}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "appears to be binary") +} + +func Test_runUpdate_DescriptionFileNotUTF8(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "bad.md") + require.NoError(t, os.WriteFile(file, []byte{0xff, 0xfe, 0xfd}, 0o600)) + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionFiles: []string{file}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid UTF-8") +} + +func Test_runUpdate_DescriptionEditor(t *testing.T) { + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = fakeEditor("written by editor") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionEditor: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "written by editor", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionEditorUsesConfigEditor(t *testing.T) { + t.Setenv("AZDO_EDITOR", "") + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("myeditor --wait") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + var command []string + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = func(c []string, file string) error { + command = c + return os.WriteFile(file, []byte("content"), 0o600) + } + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionEditor: true}) + require.NoError(t, err) + + assert.Equal(t, []string{"myeditor", "--wait"}, command) + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "content", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionEditorStripsCommentLines(t *testing.T) { + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = fakeEditor("# comment\n# also comment\n# my notes\nactual content\n") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionEditor: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "actual content", (*args.Document)[0].Value) +} + +func Test_runUpdate_DescriptionEditorEmptyAborts(t *testing.T) { + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("") + deps.stubPreflight(t, "Fabrikam") + + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = fakeEditor("") + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionEditor: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "editor produced empty description") +} + +func Test_runUpdate_DescriptionEditorNonZeroExit(t *testing.T) { + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("") + deps.stubPreflight(t, "Fabrikam") + + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = func(_ []string, _ string) error { + return errors.New("exit status 1") + } + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", descriptionEditor: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exit status 1") +} + +func Test_runUpdate_DescriptionPrecedenceEditorOverFile(t *testing.T) { + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = fakeEditor("editor content") + + file := filepath.Join(t.TempDir(), "desc.md") + require.NoError(t, os.WriteFile(file, []byte("file content"), 0o600)) + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + descriptionEditor: true, + descriptionFiles: []string{file}, + }) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "editor content", (*args.Document)[0].Value) + assert.Contains(t, deps.errOut.String(), "takes precedence over --description-file") +} + +func Test_runUpdate_DescriptionPrecedenceEditorOverInline(t *testing.T) { + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.setupEditor("") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + original := shared.ExecEditorCommand + t.Cleanup(func() { shared.ExecEditorCommand = original }) + shared.ExecEditorCommand = fakeEditor("editor content") + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + descriptionEditor: true, + description: "inline content", + }) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "editor content", (*args.Document)[0].Value) + assert.Contains(t, deps.errOut.String(), "takes precedence over --description") +} + +func Test_runUpdate_DescriptionPrecedenceFileOverInline(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "desc.md") + require.NoError(t, os.WriteFile(file, []byte("file content"), 0o600)) + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{ + targetArg: "Fabrikam/1234", + descriptionFiles: []string{file}, + description: "inline content", + }) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "file content", (*args.Document)[0].Value) + assert.Contains(t, deps.errOut.String(), "takes precedence over --description") +} + +func Test_runUpdate_DescriptionAbsent_OmitsPatchOp(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubPreflight(t, "Fabrikam") + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, deps.stdout), nil) + + err := runUpdate(deps.cmd, &updateOptions{targetArg: "Fabrikam/1234", title: "T"}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.NotEqual(t, "/fields/System.Description", *(*args.Document)[0].Path) +} + +func mustListPrinter(t *testing.T, w io.Writer) printer.Printer { + t.Helper() + tp, err := printer.NewListPrinter(w) + require.NoError(t, err) + return tp +} + +func fakeEditor(content string) func([]string, string) error { + return func(_ []string, file string) error { + return os.WriteFile(file, []byte(content), 0o600) + } +} + +type captureExporter struct { + data any +} + +func (c *captureExporter) Fields() []string { return nil } +func (c *captureExporter) Write(_ *iostreams.IOStreams, data any) error { + c.data = data + return nil +} From 747993e47a469102b65acebef8cf7f8103a3290b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 21:03:16 +0000 Subject: [PATCH 5/5] docs(boards): add documentation for work-item update command --- docs/azdo_boards_work-item.md | 4 + docs/azdo_boards_work-item_update.md | 115 +++++++++++++++++++++++++++ docs/azdo_help_reference.md | 32 ++++++++ 3 files changed, 151 insertions(+) create mode 100644 docs/azdo_boards_work-item_update.md diff --git a/docs/azdo_boards_work-item.md b/docs/azdo_boards_work-item.md index f2d7d473..7af01b0b 100644 --- a/docs/azdo_boards_work-item.md +++ b/docs/azdo_boards_work-item.md @@ -6,6 +6,7 @@ Work with Azure Boards work items. * [azdo boards work-item delete](./azdo_boards_work-item_delete.md) * [azdo boards work-item list](./azdo_boards_work-item_list.md) +* [azdo boards work-item update](./azdo_boards_work-item_update.md) ### Examples @@ -13,6 +14,9 @@ Work with Azure Boards work items. # List work items in a project azdo boards work-item list Fabrikam +# Update a work item's title +azdo boards work-item update Fabrikam/42 --title "New title" + # Delete a work item azdo boards work-item delete Fabrikam/42 --yes ``` diff --git a/docs/azdo_boards_work-item_update.md b/docs/azdo_boards_work-item_update.md new file mode 100644 index 00000000..d0537b7e --- /dev/null +++ b/docs/azdo_boards_work-item_update.md @@ -0,0 +1,115 @@ +## Command `azdo boards work-item update` + +``` +azdo boards work-item update [ORG:]PROJECT/ID [flags] +``` + +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. + + +### Options + + +* `--area` `string` + + New area path of the work item. + +* `--assigned-to` `string` + + Identity the work item is assigned to. + +* `--bypass-rules` + + Do not enforce the work item type rules on this update. + +* `--description` `string` + + New description (Markdown). Lower priority than --description-file and --description-editor. + +* `--description-editor` + + Edit description in $VISUAL/$EDITOR. Highest priority description source. + +* `--description-file` `strings` + + Read description from file (repeatable; "-" reads from stdin). Higher priority than --description. + +* `--discussion` `string` + + Comment to add to the work item discussion. + +* `--expand` `string` + + Expand parameters: None, Relations, Fields, Links, All. + +* `--fields` `strings` + + Set a field by reference name (repeatable; Ref.Name=value). + +* `--iteration` `string` + + New iteration path of the work item. + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `--open` + + Open the updated work item in the default browser. + +* `--reason` `string` + + Reason for the change of state. + +* `--state` `string` + + New state of the work item. + +* `--suppress-notifications` + + Do not fire any notifications for this change. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--title` `string` + + New title of the work item. + +* `--validate-only` + + Only validate the changes without saving the work item. + + +### ALIASES + +- `u` + +### JSON Fields + +`_links`, `commentVersionRef`, `fields`, `id`, `relations`, `rev`, `url` + +### Examples + +```bash +# update a work item's title +azdo boards work-item update Fabrikam/1234 --title "New title" + +# update description from a Markdown file +azdo boards work-item update Fabrikam/1234 --description-file ./updated-repro.md + +# edit description in $EDITOR +azdo boards work-item update Fabrikam/1234 --description-editor +``` + +### See also + +* [azdo boards work-item](./azdo_boards_work-item.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 98ec1b0b..074024eb 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -275,6 +275,38 @@ Aliases ls, l ``` +#### `azdo boards work-item update [ORG:]PROJECT/ID [flags]` + +Update a work item. + +``` + --area string New area path of the work item. + --assigned-to string Identity the work item is assigned to. + --bypass-rules Do not enforce the work item type rules on this update. + --description string New description (Markdown). Lower priority than --description-file and --description-editor. + --description-editor Edit description in $VISUAL/$EDITOR. Highest priority description source. + --description-file strings Read description from file (repeatable; "-" reads from stdin). Higher priority than --description. + --discussion string Comment to add to the work item discussion. + --expand string Expand parameters: None, Relations, Fields, Links, All. + --fields strings Set a field by reference name (repeatable; Ref.Name=value). + --iteration string New iteration path of the work item. +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. + --open Open the updated work item in the default browser. + --reason string Reason for the change of state. + --state string New state of the work item. + --suppress-notifications Do not fire any notifications for this change. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --title string New title of the work item. + --validate-only Only validate the changes without saving the work item. +``` + +Aliases + +``` +u +``` + ## `azdo co` Alias for "pr checkout"