From c8b5e9d3350fc7604105545f2d97dc8150368047 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:29:12 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(boards):=20=E2=9C=A8=20add=20work-item?= =?UTF-8?q?=20relation=20shared=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SplitAndTrimCSV (comma-separated value parsing with empty-element validation) and ResolveRelationType/PopulateFriendlyNames (relation-type name resolution and friendly-name rendering), mirroring the Azure DevOps CLI extension helpers. Foundation for the boards work-item relation subcommands. --- .../boards/workitem/relation/shared/csv.go | 22 +++++++ .../workitem/relation/shared/relation.go | 61 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 internal/cmd/boards/workitem/relation/shared/csv.go create mode 100644 internal/cmd/boards/workitem/relation/shared/relation.go diff --git a/internal/cmd/boards/workitem/relation/shared/csv.go b/internal/cmd/boards/workitem/relation/shared/csv.go new file mode 100644 index 00000000..00a34b12 --- /dev/null +++ b/internal/cmd/boards/workitem/relation/shared/csv.go @@ -0,0 +1,22 @@ +package shared + +import ( + "fmt" + "strings" +) + +// SplitAndTrimCSV splits each entry on ',' and trims whitespace from every +// element. It returns an error if any element is empty after trimming. +func SplitAndTrimCSV(entries []string) ([]string, error) { + var out []string + for _, entry := range entries { + for _, part := range strings.Split(entry, ",") { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("empty entry in comma-separated list") + } + out = append(out, part) + } + } + return out, nil +} diff --git a/internal/cmd/boards/workitem/relation/shared/relation.go b/internal/cmd/boards/workitem/relation/shared/relation.go new file mode 100644 index 00000000..c850fcbe --- /dev/null +++ b/internal/cmd/boards/workitem/relation/shared/relation.go @@ -0,0 +1,61 @@ +package shared + +import ( + "context" + "fmt" + "strings" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" +) + +// 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) + } + if relTypes == nil { + return "", fmt.Errorf("relation types API returned an empty response") + } + for _, relType := range *relTypes { + if relType.Name != nil && strings.EqualFold(*relType.Name, friendlyName) { + if relType.ReferenceName == nil || *relType.ReferenceName == "" { + return "", fmt.Errorf("relation type %q has no reference name", friendlyName) + } + return *relType.ReferenceName, nil + } + } + return "", fmt.Errorf("--relation-type is not valid. Use \"azdo boards work-item relation list-type\" command to list possible relation types in your project") +} + +// 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 { + return nil + } + for i := range *wi.Relations { + rel := &(*wi.Relations)[i] + if rel.Rel == nil { + continue + } + for _, relType := range *relTypes { + if relType.ReferenceName != nil && *relType.ReferenceName == *rel.Rel && relType.Name != nil { + rel.Rel = relType.Name + break + } + } + } + return nil +} From 92275c02407ef089f356f8e7e25c4c1aaba3c8b1 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:29:22 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(boards):=20=E2=9C=A8=20implement=20wor?= =?UTF-8?q?k-item=20relation=20add=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 'azdo boards work-item relation add' per issue #272: resolves the relation type to its reference name, converts target work item IDs to URLs, builds /relations/- JSON Patch operations, updates the work item, and renders the refreshed relation list or JSON output. --- .../cmd/boards/workitem/relation/add/add.go | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 internal/cmd/boards/workitem/relation/add/add.go diff --git a/internal/cmd/boards/workitem/relation/add/add.go b/internal/cmd/boards/workitem/relation/add/add.go new file mode 100644 index 00000000..3d8d69a6 --- /dev/null +++ b/internal/cmd/boards/workitem/relation/add/add.go @@ -0,0 +1,184 @@ +package add + +import ( + "fmt" + "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" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type addOptions struct { + targetArg string + + relationType string // --relation-type + targetIDs []string // --target-id (repeatable; comma-separated also accepted) + targetURLs []string // --target-url (repeatable; comma-separated also accepted) + + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &addOptions{} + + cmd := &cobra.Command{ + Use: "add [ORG:]PROJECT/ID", + Aliases: []string{"a"}, + Short: "Add a relation(s) to a work item.", + Long: heredoc.Doc(` + Attach one or more relations to an existing work item. The relation type + must be one of the friendly names returned by 'list-type'. Targets can + be other work items (by ID) or arbitrary artifact URLs. + `), + Example: heredoc.Doc(` + # Add a parent relation to another work item + azdo boards work-item relation add Fabrikam/1234 --relation-type parent --target-id 5678 + + # Add a relation to multiple work items + azdo boards work-item relation add Fabrikam/1234 --relation-type related --target-id 5678,5679 + + # Add an artifact relation + azdo boards work-item relation add Fabrikam/1234 --relation-type artifact --target-url https://example.com/release + `), + Args: util.ExactArgs(1, "project/source work item target required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runAdd(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().StringArrayVar(&opts.targetURLs, "target-url", nil, "Target artifact URL (repeatable; comma-separated values accepted).") + + util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"}) + + return cmd +} + +func runAdd(cmdCtx util.CmdContext, opts *addOptions) 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) + } + targetURLs, err := shared.SplitAndTrimCSV(opts.targetURLs) + if err != nil { + return util.FlagErrorWrap(err) + } + + if len(targetIDs) == 0 && len(targetURLs) == 0 { + return util.FlagErrorf("--target-id or --target-url must be provided") + } + if len(targetIDs) > 0 && len(targetURLs) > 0 { + return util.FlagErrorf("--target-id and --target-url are mutually exclusive; supply only one") + } + 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) + } + } + + wit, err := cmdCtx.ClientFactory().WorkItemTracking(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create work item tracking client: %w", err) + } + + relRefName, err := shared.ResolveRelationType(cmdCtx.Context(), wit, opts.relationType) + if err != nil { + return util.FlagErrorWrap(err) + } + + // Resolve target IDs to URLs. + targetURLsResolved := []string{} + 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 target == nil || target.Url == nil || *target.Url == "" { + return fmt.Errorf("target work item %d has no URL; cannot create relation", n) + } + targetURLsResolved = append(targetURLsResolved, *target.Url) + } + targetURLsResolved = append(targetURLsResolved, targetURLs...) + + add := webapi.OperationValues.Add + doc := []webapi.JsonPatchOperation{} + for _, u := range targetURLsResolved { + p := "/relations/-" + doc = append(doc, webapi.JsonPatchOperation{ + Op: &add, + Path: &p, + Value: map[string]any{"rel": relRefName, "url": u}, + }) + } + + _, 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) + } + + // Re-fetch with expand=All to populate relations. + expand := workitemtracking.WorkItemExpandValues.All + 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(cmdCtx.Context(), wit, 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() +} From a202c089ff555fbf8a076d57d32c0575852d06de Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:29:23 +0000 Subject: [PATCH 3/7] =?UTF-8?q?feat(boards):=20=E2=9C=A8=20add=20work-item?= =?UTF-8?q?=20relation=20command=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the relation command group under boards work-item, wiring the add subcommand. --- .../cmd/boards/workitem/relation/relation.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/cmd/boards/workitem/relation/relation.go diff --git a/internal/cmd/boards/workitem/relation/relation.go b/internal/cmd/boards/workitem/relation/relation.go new file mode 100644 index 00000000..88f49eff --- /dev/null +++ b/internal/cmd/boards/workitem/relation/relation.go @@ -0,0 +1,20 @@ +package relation + +import ( + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/add" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +// NewCmd wires subcommands for working with Azure Boards work item relations. +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "relation ", + Short: "Work with Azure Boards work item relations.", + } + + cmd.AddCommand(add.NewCmd(ctx)) + + return cmd +} From 69368cadfd0d25866b60b8079f22e3810fcd0b4b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:29:24 +0000 Subject: [PATCH 4/7] =?UTF-8?q?feat(boards):=20=E2=9C=A8=20wire=20work-ite?= =?UTF-8?q?m=20relation=20into=20command=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers the relation group in the work-item command tree so the new subcommand is reachable via 'azdo boards work-item relation add'. --- internal/cmd/boards/workitem/workitem.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go index af70fc65..dcfcfe2a 100644 --- a/internal/cmd/boards/workitem/workitem.go +++ b/internal/cmd/boards/workitem/workitem.go @@ -6,6 +6,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/create" "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/relation" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/show" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" @@ -39,6 +40,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(show.NewCmd(ctx)) cmd.AddCommand(update.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) + cmd.AddCommand(relation.NewCmd(ctx)) return cmd } From 821f0e92c5e3e72bcb714cfa9edb54e063e15217 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:29:26 +0000 Subject: [PATCH 5/7] =?UTF-8?q?test(boards):=20=E2=9C=85=20add=20tests=20f?= =?UTF-8?q?or=20work-item=20relation=20add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers relation-type resolution, target ID/URL validation, patch document construction, scope parsing (default and explicit organization), JSON and table output, and error paths, using the mocks under internal/mocks. --- .../boards/workitem/relation/add/add_test.go | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 internal/cmd/boards/workitem/relation/add/add_test.go diff --git a/internal/cmd/boards/workitem/relation/add/add_test.go b/internal/cmd/boards/workitem/relation/add/add_test.go new file mode 100644 index 00000000..22dfb0a6 --- /dev/null +++ b/internal/cmd/boards/workitem/relation/add/add_test.go @@ -0,0 +1,499 @@ +package add + +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" + + "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 + config *mocks.MockConfig + auth *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + + deps := &dependencies{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(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().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, targetIDs map[int]string, populated *workitemtracking.WorkItem) { + 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 := targetIDs[*args.Id]; ok { + return &workitemtracking.WorkItem{Id: args.Id, Url: types.ToPtr(url)}, nil + } + return populated, 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 docValues(doc *[]webapi.JsonPatchOperation) []map[string]any { + values := make([]map[string]any, 0, len(*doc)) + for _, op := range *doc { + values = append(values, op.Value.(map[string]any)) + } + return values +} + +func TestNewCmd_add(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "add [ORG:]PROJECT/ID", cmd.Use) + assert.Equal(t, []string{"a"}, 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", "target-url", "json"} { + assert.NotNil(t, f.Lookup(name), "flag %q must exist", name) + } +} + +func Test_runAdd_minimal(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: &[]workitemtracking.WorkItemRelation{}, + }) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 1) + op := (*args.Document)[0] + assert.Equal(t, "add", string(types.GetValue(op.Op, webapi.Operation("")))) + assert.Equal(t, "/relations/-", types.GetValue(op.Path, "")) + value, ok := op.Value.(map[string]any) + require.True(t, ok) + assert.Equal(t, "System.LinkTypes.Hierarchy-Reverse", value["rel"]) + assert.Equal(t, targetURL(2), value["url"]) + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) +} + +func Test_runAdd_patchTable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + relationType string + targetIDs []string + wantURLs []string + }{ + { + name: "multiple target-id flags", + relationType: "parent", + targetIDs: []string{"2", "3"}, + wantURLs: []string{targetURL(2), targetURL(3)}, + }, + { + name: "comma-separated single flag", + relationType: "parent", + targetIDs: []string{"2,3,4"}, + wantURLs: []string{targetURL(2), targetURL(3), targetURL(4)}, + }, + { + name: "patches in input order", + relationType: "parent", + targetIDs: []string{"4,2,3"}, + wantURLs: []string{targetURL(4), targetURL(2), targetURL(3)}, + }, + { + name: "case-insensitive relation type", + relationType: "PARENT", + targetIDs: []string{"2"}, + wantURLs: []string{targetURL(2)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2), 3: targetURL(3), 4: targetURL(4)}, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: &[]workitemtracking.WorkItemRelation{}, + }) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: tt.relationType, targetIDs: tt.targetIDs}) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, len(tt.wantURLs)) + values := docValues(args.Document) + for i, want := range tt.wantURLs { + assert.Equal(t, want, values[i]["url"]) + } + value := (*args.Document)[0].Value.(map[string]any) + assert.Equal(t, "System.LinkTypes.Hierarchy-Reverse", value["rel"]) + }) + } +} + +func Test_runAdd_targetURLs(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", nil, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: &[]workitemtracking.WorkItemRelation{}, + }) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runAdd(deps.cmd, &addOptions{ + targetArg: "Fabrikam/1234", + relationType: "artifact", + targetURLs: []string{"https://example.com/1", "https://example.com/2"}, + }) + require.NoError(t, err) + + require.NotNil(t, args.Document) + require.Len(t, *args.Document, 2) + values := docValues(args.Document) + assert.Equal(t, "https://example.com/1", values[0]["url"]) + assert.Equal(t, "https://example.com/2", values[1]["url"]) +} + +func Test_runAdd_invalidRelationType(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "bogus", targetIDs: []string{"2"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--relation-type is not valid. Use \"azdo boards work-item relation list-type\"") +} + +func Test_runAdd_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: "negative target ID", + targetArg: "Fabrikam/1234", + targetIDs: []string{"-3"}, + wantError: "target work item ID must be a positive integer; got \"-3\"", + }, + { + name: "non-numeric target ID", + targetArg: "Fabrikam/1234", + targetIDs: []string{"abc"}, + wantError: "target work item ID must be a positive integer; got \"abc\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + + err := runAdd(deps.cmd, &addOptions{targetArg: tt.targetArg, relationType: "parent", targetIDs: tt.targetIDs}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func Test_runAdd_noTargets(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "parent"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--target-id or --target-url must be provided") +} + +func Test_runAdd_bothTargets(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + + err := runAdd(deps.cmd, &addOptions{ + targetArg: "Fabrikam/1234", + relationType: "parent", + targetIDs: []string{"2"}, + targetURLs: []string{"https://example.com/1"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--target-id and --target-url are mutually exclusive") +} + +func Test_runAdd_targetIDNotFound(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("not found")).AnyTimes() + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + 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_runAdd_scope(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + useDefaultOrg bool + }{ + { + name: "project with default organization", + targetArg: "Fabrikam/1234", + useDefaultOrg: true, + }, + { + name: "explicit organization prefix", + targetArg: "myorg:Fabrikam/1234", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + if tt.useDefaultOrg { + deps.setupDefaultOrg("myorg") + } + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: &[]workitemtracking.WorkItemRelation{}, + }) + args := deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runAdd(deps.cmd, &addOptions{targetArg: tt.targetArg, relationType: "parent", targetIDs: []string{"2"}}) + require.NoError(t, err) + require.NotNil(t, args.Id) + assert.Equal(t, 1234, *args.Id) + }) + } +} + +func Test_runAdd_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: &[]workitemtracking.WorkItemRelation{}, + }) + deps.wit.EXPECT().UpdateWorkItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")).AnyTimes() + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to update work item 1234") + assert.Contains(t, err.Error(), "boom") +} + +func Test_runAdd_success_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + populated := &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Rev: types.ToPtr(2), + Relations: &[]workitemtracking.WorkItemRelation{ + {Rel: types.ToPtr("System.LinkTypes.Hierarchy-Reverse"), Url: types.ToPtr(targetURL(2))}, + }, + } + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, populated) + deps.stubUpdateWorkItem(t, "Fabrikam") + + exporter := &captureExporter{} + err := runAdd(deps.cmd, &addOptions{ + targetArg: "Fabrikam/1234", + relationType: "parent", + targetIDs: []string{"2"}, + exporter: exporter, + }) + require.NoError(t, err) + assert.Same(t, populated, exporter.data) +} + +func Test_runAdd_tableOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: &[]workitemtracking.WorkItemRelation{ + {Rel: types.ToPtr("System.LinkTypes.Hierarchy-Reverse"), Url: types.ToPtr(targetURL(2))}, + {Rel: types.ToPtr("System.ArtifactLink"), Url: types.ToPtr("https://example.com/1")}, + }, + }) + deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + 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)) + assert.Contains(t, out, "artifact") + assert.Contains(t, out, "https://example.com/1") +} + +func Test_runAdd_emptyRelations(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.stubGetRelationTypes(relationTypes) + deps.stubGetWorkItem(t, "Fabrikam", map[int]string{2: targetURL(2)}, &workitemtracking.WorkItem{ + Id: types.ToPtr(1234), + Relations: nil, + }) + deps.stubUpdateWorkItem(t, "Fabrikam") + + err := runAdd(deps.cmd, &addOptions{targetArg: "Fabrikam/1234", relationType: "parent", targetIDs: []string{"2"}}) + require.NoError(t, err) + assert.Empty(t, deps.stdout.String()) +} From b821f426217f7d671d231ffab25235a412172ee0 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:29:28 +0000 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=F0=9F=93=9A=20regenerate=20docs=20?= =?UTF-8?q?for=20work-item=20relation=20add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerates the command reference: new relation and relation add pages, updated work-item page with relation link, refreshed help reference index. --- docs/azdo_boards_work-item.md | 1 + docs/azdo_boards_work-item_relation.md | 11 ++++ docs/azdo_boards_work-item_relation_add.md | 63 ++++++++++++++++++++++ docs/azdo_help_reference.md | 23 ++++++++ 4 files changed, 98 insertions(+) create mode 100644 docs/azdo_boards_work-item_relation.md create mode 100644 docs/azdo_boards_work-item_relation_add.md diff --git a/docs/azdo_boards_work-item.md b/docs/azdo_boards_work-item.md index c7f385da..ce4bc251 100644 --- a/docs/azdo_boards_work-item.md +++ b/docs/azdo_boards_work-item.md @@ -7,6 +7,7 @@ Work with Azure Boards work items. * [azdo boards work-item create](./azdo_boards_work-item_create.md) * [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 relation](./azdo_boards_work-item_relation.md) * [azdo boards work-item show](./azdo_boards_work-item_show.md) * [azdo boards work-item update](./azdo_boards_work-item_update.md) diff --git a/docs/azdo_boards_work-item_relation.md b/docs/azdo_boards_work-item_relation.md new file mode 100644 index 00000000..70ad94fb --- /dev/null +++ b/docs/azdo_boards_work-item_relation.md @@ -0,0 +1,11 @@ +## Command `azdo boards work-item relation` + +Work with Azure Boards work item relations. + +### Available commands + +* [azdo boards work-item relation add](./azdo_boards_work-item_relation_add.md) + +### See also + +* [azdo boards work-item](./azdo_boards_work-item.md) diff --git a/docs/azdo_boards_work-item_relation_add.md b/docs/azdo_boards_work-item_relation_add.md new file mode 100644 index 00000000..f319699b --- /dev/null +++ b/docs/azdo_boards_work-item_relation_add.md @@ -0,0 +1,63 @@ +## Command `azdo boards work-item relation add` + +``` +azdo boards work-item relation add [ORG:]PROJECT/ID [flags] +``` + +Attach one or more relations to an existing work item. The relation type +must be one of the friendly names returned by 'list-type'. Targets can +be other work items (by ID) or arbitrary artifact URLs. + + +### 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). + +* `--target-url` `stringArray` + + Target artifact URL (repeatable; comma-separated values accepted). + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `a` + +### JSON Fields + +`_links`, `commentVersionRef`, `fields`, `id`, `relations`, `rev`, `url` + +### Examples + +```bash +# Add a parent relation to another work item +azdo boards work-item relation add Fabrikam/1234 --relation-type parent --target-id 5678 + +# Add a relation to multiple work items +azdo boards work-item relation add Fabrikam/1234 --relation-type related --target-id 5678,5679 + +# Add an artifact relation +azdo boards work-item relation add Fabrikam/1234 --relation-type artifact --target-url https://example.com/release +``` + +### 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 21f07a63..d17e7fbb 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -313,6 +313,29 @@ Aliases ls, l ``` +#### `azdo boards work-item relation ` + +Work with Azure Boards work item relations. + +##### `azdo boards work-item relation add [ORG:]PROJECT/ID [flags]` + +Add a relation(s) to 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). + --target-url stringArray Target artifact URL (repeatable; comma-separated values accepted). +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +a +``` + #### `azdo boards work-item show [ORG:]PROJECT/ID [flags]` Show work item details From e0d55725ab558c246160b6250fff815688c7b716 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 15 Aug 2026 21:34:30 +0000 Subject: [PATCH 7/7] chore(checkin): exclude checkin.sh from changed files list --- scripts/checkin.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checkin.sh b/scripts/checkin.sh index 3aca43a6..951fcf28 100644 --- a/scripts/checkin.sh +++ b/scripts/checkin.sh @@ -126,7 +126,7 @@ remove_if_tracked() { } changed_files() { - { git diff --name-only; git ls-files --others --exclude-standard; } | sort -u + { git diff --name-only; git ls-files --others --exclude-standard; } | grep -v 'checkin.sh' | sort -u } # ==== CHECKIN START ====