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" 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) { 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) + } +} 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/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 +} 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