diff --git a/docs/azdo_boards_work-item_relation.md b/docs/azdo_boards_work-item_relation.md index 70ad94fb..3632d398 100644 --- a/docs/azdo_boards_work-item_relation.md +++ b/docs/azdo_boards_work-item_relation.md @@ -5,6 +5,7 @@ Work with Azure Boards work item relations. ### Available commands * [azdo boards work-item relation add](./azdo_boards_work-item_relation_add.md) +* [azdo boards work-item relation remove](./azdo_boards_work-item_relation_remove.md) ### See also diff --git a/docs/azdo_boards_work-item_relation_remove.md b/docs/azdo_boards_work-item_relation_remove.md new file mode 100644 index 00000000..7edea37c --- /dev/null +++ b/docs/azdo_boards_work-item_relation_remove.md @@ -0,0 +1,61 @@ +## Command `azdo boards work-item relation remove` + +``` +azdo boards work-item relation remove [ORG:]PROJECT/ID [flags] +``` + +Detach one or more relations from an existing work item. The relation +type must be one of the friendly names returned by 'list-type'. +Targets are specified by work item ID. + + +### Options + + +* `-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. + +* `--relation-type` `string` + + Relation type (friendly name, e.g. parent, child, related). + +* `--target-id` `stringArray` + + Target work item ID (repeatable; comma-separated values accepted). + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `-y`, `--yes` + + Skip the confirmation prompt. + + +### ALIASES + +- `r` +- `rm` + +### JSON Fields + +`_links`, `commentVersionRef`, `fields`, `id`, `relations`, `rev`, `url` + +### Examples + +```bash +# Remove a parent relation to another work item +azdo boards work-item relation remove Fabrikam/1234 --relation-type parent --target-id 5678 --yes + +# Remove relations to multiple work items +azdo boards work-item relation remove Fabrikam/1234 --relation-type related --target-id 5678,5679 --yes +``` + +### See also + +* [azdo boards work-item relation](./azdo_boards_work-item_relation.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index d17e7fbb..47dc619f 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -336,6 +336,25 @@ Aliases a ``` +##### `azdo boards work-item relation remove [ORG:]PROJECT/ID [flags]` + +Remove a relation(s) from a 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. + --relation-type string Relation type (friendly name, e.g. parent, child, related). + --target-id stringArray Target work item ID (repeatable; comma-separated values accepted). +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +-y, --yes Skip the confirmation prompt. +``` + +Aliases + +``` +r, rm +``` + #### `azdo boards work-item show [ORG:]PROJECT/ID [flags]` Show work item details diff --git a/internal/cmd/boards/workitem/relation/add/add.go b/internal/cmd/boards/workitem/relation/add/add.go index 3d8d69a6..68c8d039 100644 --- a/internal/cmd/boards/workitem/relation/add/add.go +++ b/internal/cmd/boards/workitem/relation/add/add.go @@ -107,7 +107,12 @@ func runAdd(cmdCtx util.CmdContext, opts *addOptions) error { return fmt.Errorf("failed to create work item tracking client: %w", err) } - relRefName, err := shared.ResolveRelationType(cmdCtx.Context(), wit, opts.relationType) + relTypes, err := wit.GetRelationTypes(cmdCtx.Context(), workitemtracking.GetRelationTypesArgs{}) + if err != nil { + return fmt.Errorf("failed to get relation types: %w", err) + } + + relRefName, err := shared.ResolveRelationType(relTypes, opts.relationType) if err != nil { return util.FlagErrorWrap(err) } @@ -161,7 +166,7 @@ func runAdd(cmdCtx util.CmdContext, opts *addOptions) error { return fmt.Errorf("failed to get work item %d: %w", id, err) } - if err := shared.PopulateFriendlyNames(cmdCtx.Context(), wit, populated); err != nil { + if err := shared.PopulateFriendlyNames(relTypes, populated); err != nil { return err } diff --git a/internal/cmd/boards/workitem/relation/relation.go b/internal/cmd/boards/workitem/relation/relation.go index 88f49eff..805e7174 100644 --- a/internal/cmd/boards/workitem/relation/relation.go +++ b/internal/cmd/boards/workitem/relation/relation.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/add" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/remove" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -15,6 +16,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(add.NewCmd(ctx)) + cmd.AddCommand(remove.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/boards/workitem/relation/remove/remove.go b/internal/cmd/boards/workitem/relation/remove/remove.go new file mode 100644 index 00000000..bce6b94a --- /dev/null +++ b/internal/cmd/boards/workitem/relation/remove/remove.go @@ -0,0 +1,231 @@ +package remove + +import ( + "fmt" + "sort" + "strconv" + + "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" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/shared" + wishared "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" +) + +type removeOptions struct { + targetArg string + + relationType string // --relation-type + targetIDs []string // --target-id (repeatable; comma-separated also accepted) + yes bool // --yes + + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &removeOptions{} + + cmd := &cobra.Command{ + Use: "remove [ORG:]PROJECT/ID", + Aliases: []string{"r", "rm"}, + Short: "Remove a relation(s) from a work item.", + Long: heredoc.Doc(` + Detach one or more relations from an existing work item. The relation + type must be one of the friendly names returned by 'list-type'. + Targets are specified by work item ID. + `), + Example: heredoc.Doc(` + # Remove a parent relation to another work item + azdo boards work-item relation remove Fabrikam/1234 --relation-type parent --target-id 5678 --yes + + # Remove relations to multiple work items + azdo boards work-item relation remove Fabrikam/1234 --relation-type related --target-id 5678,5679 --yes + `), + Args: util.ExactArgs(1, "project/source work item target required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runRemove(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.relationType, "relation-type", "", "Relation type (friendly name, e.g. parent, child, related).") + cmd.Flags().StringArrayVar(&opts.targetIDs, "target-id", nil, "Target work item ID (repeatable; comma-separated values accepted).") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt.") + + util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"}) + + return cmd +} + +func runRemove(cmdCtx util.CmdContext, opts *removeOptions) 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]) + } + + targetIDs, err := shared.SplitAndTrimCSV(opts.targetIDs) + if err != nil { + return util.FlagErrorWrap(err) + } + if len(targetIDs) == 0 { + return util.FlagErrorf("--target-id must be provided") + } + for _, tid := range targetIDs { + n, err := strconv.Atoi(tid) + if err != nil || n <= 0 { + return util.FlagErrorf("target work item ID must be a positive integer; got %q", tid) + } + } + + if !opts.yes { + if !ios.CanPrompt() { + return util.FlagErrorf("--yes required when not running interactively") + } + ios.StopProgressIndicator() + prompter, err := cmdCtx.Prompter() + if err != nil { + return err + } + confirmed, err := prompter.Confirm("Are you sure you want to remove this relation(s)?", false) + if err != nil { + return err + } + if !confirmed { + return util.ErrCancel + } + ios.StartProgressIndicator() + } + + wit, err := cmdCtx.ClientFactory().WorkItemTracking(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create work item tracking client: %w", err) + } + + relTypes, err := wit.GetRelationTypes(cmdCtx.Context(), workitemtracking.GetRelationTypesArgs{}) + if err != nil { + return fmt.Errorf("failed to get relation types: %w", err) + } + + relRefName, err := shared.ResolveRelationType(relTypes, opts.relationType) + if err != nil { + return util.FlagErrorWrap(err) + } + + // Resolve target IDs to URLs. + targetURLs := make(map[string]struct{}, len(targetIDs)) + for _, tid := range targetIDs { + n, _ := strconv.Atoi(tid) + target, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{ + Project: types.ToPtr(scope.Project), + Id: &n, + }) + if err != nil { + return fmt.Errorf("failed to resolve target work item %d: %w", n, err) + } + if !wishared.BelongsToProject(target, scope.Project) { + return fmt.Errorf("target work item %d does not belong to project %q", n, scope.Project) + } + if target.Url == nil || *target.Url == "" { + return fmt.Errorf("target work item %d has no URL; cannot remove relation", n) + } + targetURLs[*target.Url] = struct{}{} + } + + // Fetch the source work item to find matching relations. + expand := workitemtracking.WorkItemExpandValues.All + src, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{ + Project: types.ToPtr(scope.Project), + Id: &id, + Expand: &expand, + }) + if err != nil { + return fmt.Errorf("failed to get work item %d: %w", id, err) + } + if !wishared.BelongsToProject(src, scope.Project) { + return fmt.Errorf("work item %d does not belong to project %q", id, scope.Project) + } + + // Build a list of indices in reverse order (Decision 14). + indices := []int{} + if src.Relations != nil { + for i, rel := range *src.Relations { + if rel.Rel == nil || rel.Url == nil { + continue + } + if *rel.Rel != relRefName { + continue + } + if _, ok := targetURLs[*rel.Url]; !ok { + continue + } + indices = append(indices, i) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(indices))) + + if len(indices) != len(targetIDs) { + return util.FlagErrorf("Id(s) supplied in --target-id is not valid") + } + + remove := webapi.OperationValues.Remove + doc := []webapi.JsonPatchOperation{} + for _, idx := range indices { + p := fmt.Sprintf("/relations/%d", idx) + doc = append(doc, webapi.JsonPatchOperation{Op: &remove, Path: &p}) + } + + _, err = wit.UpdateWorkItem(cmdCtx.Context(), workitemtracking.UpdateWorkItemArgs{ + Project: types.ToPtr(scope.Project), + Document: &doc, + Id: &id, + }) + if err != nil { + return fmt.Errorf("failed to update work item %d: %w", id, err) + } + + populated, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{ + Project: types.ToPtr(scope.Project), + Id: &id, + Expand: &expand, + }) + if err != nil { + return fmt.Errorf("failed to get work item %d: %w", id, err) + } + + if err := shared.PopulateFriendlyNames(relTypes, populated); err != nil { + return err + } + + if opts.exporter != nil { + return opts.exporter.Write(ios, populated) + } + tp, err := cmdCtx.Printer("list") + if err != nil { + return err + } + tp.AddColumns("TYPE", "URL") + if populated.Relations != nil { + for _, rel := range *populated.Relations { + tp.AddField(types.GetValue(rel.Rel, "")) + tp.AddField(types.GetValue(rel.Url, "")) + tp.EndRow() + } + } + return tp.Render() +} diff --git a/internal/cmd/boards/workitem/relation/remove/remove_test.go b/internal/cmd/boards/workitem/relation/remove/remove_test.go new file mode 100644 index 00000000..938c7808 --- /dev/null +++ b/internal/cmd/boards/workitem/relation/remove/remove_test.go @@ -0,0 +1,542 @@ +package remove + +import ( + "bytes" + "context" + "errors" + "io" + "strconv" + "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" + + wishared "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" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type dependencies struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + prompter *mocks.MockPrompter + config *mocks.MockConfig + auth *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func newDependencies(t *testing.T, organization string, canPrompt bool) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdinTTY(canPrompt) + io.SetStdoutTTY(canPrompt) + io.SetStderrTTY(canPrompt) + + deps := &dependencies{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + prompter: mocks.NewMockPrompter(ctrl), + config: mocks.NewMockConfig(ctrl), + auth: mocks.NewMockAuthConfig(ctrl), + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.cmd.EXPECT().Prompter().Return(deps.prompter, nil).AnyTimes() + deps.cmd.EXPECT().Config().Return(deps.config, nil).AnyTimes() + deps.config.EXPECT().Authentication().Return(deps.auth).AnyTimes() + deps.cmd.EXPECT().Printer("list").Return(mustListPrinter(t, out), nil).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) stubGetRelationTypes(types []workitemtracking.WorkItemRelationType) { + d.wit.EXPECT().GetRelationTypes(gomock.Any(), gomock.Any()).Return(&types, nil).AnyTimes() +} + +func (d *dependencies) stubGetWorkItem(t *testing.T, project string, targets map[int]string, source, populated *workitemtracking.WorkItem) { + sourceFetches := 0 + 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) + if url, ok := targets[*args.Id]; ok { + fields := map[string]interface{}{wishared.TeamProjectField: project} + return &workitemtracking.WorkItem{Id: args.Id, Url: types.ToPtr(url), Fields: &fields}, nil + } + sourceFetches++ + if sourceFetches > 1 && populated != nil { + return populated, nil + } + return source, nil + }, + ).AnyTimes() +} + +func (d *dependencies) stubUpdateWorkItem(t *testing.T, project string) *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 + return &workitemtracking.WorkItem{Id: args.Id}, nil + }, + ).AnyTimes() + return &captured +} + +func mustListPrinter(t *testing.T, w io.Writer) printer.Printer { + t.Helper() + tp, err := printer.NewListPrinter(w) + require.NoError(t, err) + return tp +} + +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 +} + +var relationTypes = []workitemtracking.WorkItemRelationType{ + {Name: types.ToPtr("parent"), ReferenceName: types.ToPtr("System.LinkTypes.Hierarchy-Reverse")}, + {Name: types.ToPtr("artifact"), ReferenceName: types.ToPtr("System.ArtifactLink")}, +} + +func targetURL(id int) string { + return "https://dev.azure.com/myorg/_apis/wit/workItems/" + strconv.Itoa(id) +} + +func rel(relRef, url string) workitemtracking.WorkItemRelation { + return workitemtracking.WorkItemRelation{Rel: types.ToPtr(relRef), Url: types.ToPtr(url)} +} + +func sourceWorkItem(id int, project string, relations *[]workitemtracking.WorkItemRelation) *workitemtracking.WorkItem { + fields := map[string]interface{}{wishared.TeamProjectField: project} + return &workitemtracking.WorkItem{Id: types.ToPtr(id), Fields: &fields, Relations: relations} +} + +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_remove(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "remove [ORG:]PROJECT/ID", cmd.Use) + assert.Equal(t, []string{"r", "rm"}, 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{"relation-type", "target-id", "yes", "json"} { + assert.NotNil(t, f.Lookup(name), "flag %q must exist", name) + } +} + +func Test_runRemove_minimal(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + op := (*args.Document)[0] + assert.Equal(t, "remove", string(types.GetValue(op.Op, webapi.Operation("")))) + assert.Equal(t, "/relations/0", types.GetValue(op.Path, "")) + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) +} + +func Test_runRemove_multipleTargets(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2), 3: targetURL(3)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + rel("System.ArtifactLink", "https://example.com/1"), + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(3)), + }), nil) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2", "3"}, yes: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 2) + assert.Equal(t, []string{"/relations/2", "/relations/0"}, patchPaths(args.Document)) +} + +func Test_runRemove_commaSeparated(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2), 3: targetURL(3)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + rel("System.ArtifactLink", "https://example.com/1"), + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(3)), + }), nil) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2,3"}, yes: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 2) + assert.Equal(t, []string{"/relations/2", "/relations/0"}, patchPaths(args.Document)) +} + +func Test_runRemove_noMatch(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", nil), nil) + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Id(s) supplied in --target-id is not valid") +} + +func Test_runRemove_partialMatch(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2), 3: targetURL(3)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2", "3"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Id(s) supplied in --target-id is not valid") +} + +func Test_runRemove_invalidRelationType(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "bogus", targetIDs: []string{"2"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--relation-type is not valid. Use \"azdo boards work-item relation list-type\"") +} + +func Test_runRemove_invalidIDs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + targetIDs []string + wantError string + }{ + { + name: "non-numeric source ID", + targetArg: "Fabrikam/abc", + targetIDs: []string{"2"}, + wantError: "work item ID must be a positive integer; got \"abc\"", + }, + { + name: "zero source ID", + targetArg: "Fabrikam/0", + targetIDs: []string{"2"}, + wantError: "work item ID must be a positive integer", + }, + { + name: "negative source ID", + targetArg: "Fabrikam/-5", + targetIDs: []string{"2"}, + wantError: "work item ID must be a positive integer", + }, + { + name: "non-numeric target ID", + targetArg: "Fabrikam/1234", + targetIDs: []string{"abc"}, + wantError: "target work item ID must be a positive integer; got \"abc\"", + }, + { + name: "negative target ID", + targetArg: "Fabrikam/1234", + targetIDs: []string{"-3"}, + wantError: "target work item ID must be a positive integer; got \"-3\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + + err := runRemove(deps.cmd, &removeOptions{targetArg: tt.targetArg, relationType: "parent", targetIDs: tt.targetIDs, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func Test_runRemove_noTargets(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--target-id must be provided") +} + +func Test_runRemove_targetIDNotFound(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("not found")).AnyTimes() + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve target work item 2") + assert.Contains(t, err.Error(), "not found") +} + +func Test_runRemove_confirmationPrompt_userConfirms(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + deps.prompter.EXPECT().Confirm( + "Are you sure you want to remove this relation(s)?", + false, + ).Return(true, nil) + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) +} + +func Test_runRemove_confirmationPrompt_userDeclines(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", true) + deps.setupDefaultOrg("myorg") + deps.prompter.EXPECT().Confirm(gomock.Any(), false).Return(false, nil) + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + require.ErrorIs(t, err, util.ErrCancel) + assert.Empty(t, deps.stdout.String()) +} + +func Test_runRemove_explicitOrganizationProject(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "myorg:Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.NoError(t, err) + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) +} + +func Test_runRemove_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + deps.wit.EXPECT().UpdateWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")).AnyTimes() + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to update work item 1234") + assert.Contains(t, err.Error(), "boom") +} + +func Test_runRemove_success_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + populated := sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, populated, populated) + deps.stubUpdateWorkItem(t, "Fabrikam") + + exporter := &captureExporter{} + err := runRemove(deps.cmd, &removeOptions{ + targetArg: "Fabrikam/1234", + relationType: "parent", + targetIDs: []string{"2"}, + yes: true, + exporter: exporter, + }) + require.NoError(t, err) + assert.Same(t, populated, exporter.data) +} + +func Test_runRemove_tableOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.NoError(t, err) + + out := deps.stdout.String() + assert.Contains(t, out, "TYPE") + assert.Contains(t, out, "URL") + assert.Contains(t, out, "parent") + assert.Contains(t, out, targetURL(2)) +} + +func Test_runRemove_emptyRelationsAfterRemove(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{})) + deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.NoError(t, err) + assert.Empty(t, deps.stdout.String()) +} + +func Test_runRemove_relationTypeMatchIsCaseInsensitive(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, sourceWorkItem(1234, "Fabrikam", &[]workitemtracking.WorkItemRelation{ + rel("System.LinkTypes.Hierarchy-Reverse", targetURL(2)), + }), nil) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "PARENT", targetIDs: []string{"2"}, yes: true}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + assert.Equal(t, "/relations/0", types.GetValue((*args.Document)[0].Path, "")) +} + +func Test_runRemove_projectMismatchSource(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemArgs) (*workitemtracking.WorkItem, error) { + require.NotNil(t, args.Id) + if *args.Id == 2 { + fields := map[string]interface{}{wishared.TeamProjectField: "Fabrikam"} + return &workitemtracking.WorkItem{Id: args.Id, Url: types.ToPtr(targetURL(2)), Fields: &fields}, nil + } + fields := map[string]interface{}{wishared.TeamProjectField: "OtherProject"} + return &workitemtracking.WorkItem{Id: args.Id, Fields: &fields}, nil + }, + ).AnyTimes() + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), `work item 1234 does not belong to project "Fabrikam"`) +} + +func Test_runRemove_projectMismatchTarget(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg", false) + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemArgs) (*workitemtracking.WorkItem, error) { + require.NotNil(t, args.Id) + fields := map[string]interface{}{wishared.TeamProjectField: "OtherProject"} + return &workitemtracking.WorkItem{Id: args.Id, Url: types.ToPtr(targetURL(2)), Fields: &fields}, nil + }, + ).AnyTimes() + + err := runRemove(deps.cmd, &removeOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}, yes: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), `target work item 2 does not belong to project "Fabrikam"`) +} diff --git a/internal/cmd/boards/workitem/relation/shared/relation.go b/internal/cmd/boards/workitem/relation/shared/relation.go index c850fcbe..c1fcdc99 100644 --- a/internal/cmd/boards/workitem/relation/shared/relation.go +++ b/internal/cmd/boards/workitem/relation/shared/relation.go @@ -1,7 +1,6 @@ package shared import ( - "context" "fmt" "strings" @@ -9,14 +8,9 @@ import ( ) // ResolveRelationType resolves a friendly relation-type name to its -// referenceName via a case-insensitive match against the organization's -// relation types, mirroring get_system_relation_name in the Azure DevOps -// CLI extension. -func ResolveRelationType(ctx context.Context, wit workitemtracking.Client, friendlyName string) (string, error) { - relTypes, err := wit.GetRelationTypes(ctx, workitemtracking.GetRelationTypesArgs{}) - if err != nil { - return "", fmt.Errorf("failed to get relation types: %w", err) - } +// referenceName via a case-insensitive match against the relation types, +// mirroring get_system_relation_name in the Azure DevOps CLI extension. +func ResolveRelationType(relTypes *[]workitemtracking.WorkItemRelationType, friendlyName string) (string, error) { if relTypes == nil { return "", fmt.Errorf("relation types API returned an empty response") } @@ -34,15 +28,8 @@ func ResolveRelationType(ctx context.Context, wit workitemtracking.Client, frien // PopulateFriendlyNames replaces each relation's Rel (referenceName) with its // friendly Name, mirroring fill_friendly_name_for_relations_in_work_item in // the Azure DevOps CLI extension. Relations are mutated in place. -func PopulateFriendlyNames(ctx context.Context, wit workitemtracking.Client, wi *workitemtracking.WorkItem) error { - if wi == nil || wi.Relations == nil { - return nil - } - relTypes, err := wit.GetRelationTypes(ctx, workitemtracking.GetRelationTypesArgs{}) - if err != nil { - return fmt.Errorf("failed to get relation types: %w", err) - } - if relTypes == nil { +func PopulateFriendlyNames(relTypes *[]workitemtracking.WorkItemRelationType, wi *workitemtracking.WorkItem) error { + if wi == nil || wi.Relations == nil || relTypes == nil { return nil } for i := range *wi.Relations {