Skip to content

Implement azdo boards work-item relation add command #272

@tmeckel

Description

@tmeckel

Sub-issue of new boards work-item relation umbrella. Hardened spec — do not re-derive decisions. Sibling of #272 (list-type), #273 (remove), #274 (show).

Command Description

Attach a relation (link) from one Azure Boards work item to one or more target work items, or to one or more arbitrary artifact URLs. Mirrors az boards work-item relation add. The command builds a JSON-Patch document containing add operations on /relations/- and sends it to the server.

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

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

The Python reference implementation is at azure-devops/azext_devops/dev/boards/relations.py:21-58 (add_relation function). The function:

  1. Validates that exactly one of --target-id / --target-url is supplied.
  2. Looks up the referenceName of the relation type via GetRelationTypes (case-insensitive friendly name → referenceName).
  3. For each target ID, fetches the target work item via GetWorkItem(id) to obtain its url.
  4. For each target URL, uses the URL directly.
  5. Sends a PATCH with one add op per relation: {op: add, path: /relations/-, value: {rel, url}}.
  6. Fetches the updated work item with expand='All' to populate the response with friendly relation names.

Locked Decisions

# Decision Rationale
1 Org-scoped target. Use: "add [ORGANIZATION/]ID". The source work item ID is unique within the organization. Matches az boards work-item relation add (no project positional). Symmetric with #269 (delete) and #270 (update).
2 Aliases: []string{"a"}. Per AGENTS.md update command convention.
3 cobra.ExactArgs(1). One positional target: the source work item ID.
4 Parse the single positional with util.ParseTargetWithDefaultOrganization(ctx, args[0]). scope.Targets[0] is the ID string. Existing helper handles 1- and 2-segment forms. Symmetric with #269 and #270.
5 No --project flag. The PATCH URL is org-scoped (PATCH /_apis/wit/workitems/{id}); the Python never passes a project to client.update_work_item. Pass nil for UpdateWorkItemArgs.Project. Matches az boards work-item relation add exactly.
6 ID must parse as a positive integer. If strconv.Atoi(scope.Targets[0]) fails or returns <= 0, return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0]) and do not call the SDK. Defends against typo'd IDs without a wasted round-trip. Symmetric with #269 Decision 14 and #270 Decision 6.
7 Exactly one of --target-id / --target-url is required. Reject if both are empty (return util.FlagErrorf with the exact message "--target-id or --target-url must be provided") and reject if both are set (return util.FlagErrorf with "--target-id and --target-url are mutually exclusive; supply only one"). Mirrors Python's if target_id is None and target_url is None: raise CLIError('--target-id or --target-url must be provided').
8 --target-id is repeatable; multiple invocations concatenate. Each ID must parse as a positive integer. Empty entries are skipped. Mirrors Python's target_id.split(',') semantics with Go-native flag repetition. Decision 8 supersedes the comma-separated form — comma-separated values are also accepted (split on , after collecting) to match az ergonomics, but the primary form is repeated flag invocation.
9 --target-url is repeatable with the same semantics. Mirrors Python's target_url.split(',').
10 --relation-type is a single friendly name (e.g., parent, child, related, duplicate, successor, predecessor, artifact). Case-insensitive match against the list returned by GetRelationTypes. Mirrors Python's get_system_relation_name.
11 Invalid relation type surfaces a CLI error with the exact Python message: --relation-type is not valid. Use "azdo boards work-item relation list-type" command to list possible relation types in your project. Mirrors get_system_relation_name (relations.py:131-135).
12 No --expand flag. All responses use expand='All' internally. The user does not need to choose. Mirrors Python's hard-coded expand='All'.
13 No confirmation prompt. The operation is reversible via remove. Mirrors Python (the commands.py:97 only adds a confirmation to remove, not add).
14 For each target ID, call GetWorkItem(id) to obtain its url and use that URL in the patch op. If the call fails, return the wrapped SDK error. This mirrors the Python's query_by_wiql validation but uses GetWorkItem per ID (cheaper for small counts; deterministic for tests). Matches Python's intent: reject invalid target IDs before sending a PATCH.
15 Validate all target IDs succeed before sending the PATCH. If any GetWorkItem for a target ID fails, abort with the wrapped error from the first failed call. Mirrors Python's "all or nothing" validation (len(target_work_items) != len(target_work_item_ids): raise CLIError).
16 JSON output passes the raw SDK *WorkItem (the GetWorkItem(id, expand=All) response after the PATCH succeeds) to opts.exporter.Write. Do not introduce a view struct. Mirrors #203, #269, #270 conventions. The user can filter to relations via --jq.
17 Default output: table with columns TYPE, URL (mirroring Python's transform_work_item_relations columns Relation Type, URL). The table is the work item's relations array after friendly-name resolution. Mirrors Python transform_work_item_relations (_format.py:14-24).
18 Friendly-name resolution: before rendering, replace each Rel (referenceName) with its Name using the list returned by GetRelationTypes. This is done in the shared helper internal/cmd/boards/workitem/relation/shared/relation.go (introduced by the umbrella). Mirrors Python's fill_friendly_name_for_relations_in_work_item (relations.py:107-115).
19 Empty source work item relations (no relations before or after) renders as an empty table — no error. Matches Python: if result['relations'] is None: return [].
20 Uses vendored workitemtracking.Client.{UpdateWorkItem, GetWorkItem, GetRelationTypes}. Vendor verified at vendor/.../v7/workitemtracking/client.go:205, 137, 113.
21 No new mocks needed. internal/mocks/workitemtracking_client_mock.go:849 (GetWorkItem), 669 (GetRelationTypes), 1358 (UpdateWorkItem) already exist.
22 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.
23 No --organization flag; organization comes from ParseTargetWithDefaultOrganization or default config. Mirrors az ergonomics.
24 No --detect flag. az used this for autodetecting the organization from a .azuredevops config; we resolve via ParseTargetWithDefaultOrganization. Mirrors azdo conventions; not in azdo scope.
25 No JsonPatchOperation value is wrapped beyond {rel, url}. No attributes, no null for missing fields. Matches Python's _create_patch_operation (relations.py:138-146).
26 Patch doc order is: one op per target, in the order the user supplied them. Tests assert order. Mirrors Python's append-in-input-order.

Command Signature

var addCmd = &cobra.Command{
    Use:     "add [ORGANIZATION/]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.
    `),
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runAdd(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Maps to Notes
--id not a flag — positional Source work item ID (positional [ORGANIZATION/]ID).
--relation-type relationType (friendly name) Required. Case-insensitive.
--target-id (repeatable) targetId slice Required if --target-url is not set.
--target-url (repeatable) targetUrl slice Required if --target-id is not set.
--organization *Path.Organization Default org from config.

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

Code Skeleton (canonical, copy verbatim)

§1. addOptions struct

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
}

§2. runAdd skeleton

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

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

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

    // Collect and expand target IDs / URLs.
    targetIDs, err := util.SplitAndTrimCSV(opts.targetIDs)
    if err != nil { return util.FlagErrorWrap(err) }
    targetURLs, err := util.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 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{Id: &n})
        if err != nil {
            return fmt.Errorf("failed to resolve target work item %d: %w", n, err)
        }
        if 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},
        })
    }

    res, err := wit.UpdateWorkItem(cmdCtx.Context(), workitemtracking.UpdateWorkItemArgs{
        Document: &doc,
        Id:       &id,
    })
    if err != nil { return err }

    // Re-fetch with expand=All to populate relations.
    expand := workitemtracking.WorkItemExpandValues.All
    populated, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{
        Id:     &id,
        Expand: &expand,
    })
    if 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()
}

util.SplitAndTrimCSV is a new shared helper at internal/cmd/util/csv.go that splits a []string on , and trims whitespace from each element; returns an error if any element is empty after trimming. If this helper does not yet exist in the umbrella's #138's implementation, the add leaf must create it in internal/cmd/boards/workitem/relation/shared/csv.go.

JSON Output Contract

Pass the raw *workitemtracking.WorkItem returned by the post-PATCH GetWorkItem (with expand=All) to opts.exporter.Write. No view struct.

JSON fields exposed: id, rev, fields, url, _links, relations, commentVersionRef.

Table Output Contract

Use ctx.Printer("list") with 2 columns: TYPE, URL. The TYPE cell is the relation type's friendly name (not referenceName) — friendly-name resolution is performed by the shared helper.

Command Wiring

  1. Create internal/cmd/boards/workitem/relation/add/add.go with package add, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/add" to internal/cmd/boards/workitem/relation/relation.go.
  3. Register in relation.go with cmd.AddCommand(add.NewCmd(ctx)).
  4. Reuse internal/cmd/boards/workitem/relation/shared/relation.go (introduced by the umbrella) — shared.ResolveRelationType, shared.PopulateFriendlyNames.

API Surface

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

// 1. Resolve friendly relation type name to referenceName
relTypes, err := wit.GetRelationTypes(ctx, workitemtracking.GetRelationTypesArgs{})

// 2. Resolve each target ID to its URL
target, err := wit.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{Id: &n})

// 3. PATCH the source work item
res, err := wit.UpdateWorkItem(ctx, workitemtracking.UpdateWorkItemArgs{
    Document: &doc,
    Id:       &id,
})

// 4. Re-fetch with expand=All
expand := workitemtracking.WorkItemExpandValues.All
populated, err := wit.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
    Id:     &id,
    Expand: &expand,
})

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

Reference Existing Patterns

  • internal/cmd/boards/workitem/show — sibling for the org-scoped ParseTargetWithDefaultOrganization usage.
  • internal/cmd/boards/workitem/shared/description.go (introduced by feat: Implement azdo boards work-item create command #203) — reference for the shared helper pattern.
  • internal/cmd/security/group/update/update.go:103-124 — reference for the 4-line patch-doc append pattern.
  • internal/cmd/util/flag_error.goutil.FlagErrorf for the CLI error formatting (used in Decision 6 and Decision 7).

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

  • AzDO Extension azure-devops/azext_devops/dev/boards/relations.py:21-58 - add_relation function. Confirms: org-scoped, --target-id and --target-url mutually exclusive, comma-separated targets, expand='All' after write, friendly-name fill.
  • AzDO Extension azure-devops/azext_devops/dev/boards/relations.py:131-135 - get_system_relation_name function. Confirms: case-insensitive match on name, error message wording.
  • AzDO Extension azure-devops/azext_devops/dev/boards/_format.py:14-24 - transform_work_item_relations confirms table columns Relation Type, URL.
  • AzDO MCP Server (TypeScript): no dedicated relation tool. Relation handling is folded into the read tool's expand: relations parameter.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_add Inspect the command struct. Use == "add [ORGANIZATION/]ID", Aliases == ["a"], Args == cobra.ExactArgs(1).
Test_runAdd_minimal --relation-type parent --target-id 2. Mock returns a WorkItem with one relation. One add op on /relations/- with value.rel = "System.LinkTypes.Hierarchy-Reverse" and value.url = "https://dev.azure.com/.../workitems/2".
Test_runAdd_multipleTargetIDs --target-id 2 --target-id 3. Two add ops in order.
Test_runAdd_multipleTargetsSingleFlag --target-id 2,3,4 (comma-separated). Three add ops in order.
Test_runAdd_targetURLs --relation-type artifact --target-url https://.../1 --target-url https://.../2. Two add ops using the URLs directly; no GetWorkItem calls for the targets.
Test_runAdd_invalidRelationType --relation-type bogus. Returns util.FlagErrorf with the exact message containing "--relation-type is not valid. Use \"azdo boards work-item relation list-type\"". SDK is not called for UpdateWorkItem.
Test_runAdd_invalidSourceID Positional is "abc". Returns util.FlagErrorf with "work item ID must be a positive integer; got \"abc\"". SDK is not called.
Test_runAdd_zeroSourceID Positional is "0". Returns util.FlagErrorf. SDK is not called.
Test_runAdd_negativeSourceID Positional is "-5". Returns util.FlagErrorf. SDK is not called.
Test_runAdd_noTargets No --target-id and no --target-url. Returns util.FlagErrorf with "--target-id or --target-url must be provided". SDK is not called.
Test_runAdd_bothTargets Both --target-id and --target-url set. Returns util.FlagErrorf with "--target-id and --target-url are mutually exclusive". SDK is not called.
Test_runAdd_invalidTargetID --target-id -3 or abc. Returns util.FlagErrorf with "target work item ID must be a positive integer". SDK is not called.
Test_runAdd_targetIDNotFound Mock GetWorkItem for the target returns 404. Returns wrapped error containing the target ID; PATCH is not sent.
Test_runAdd_orgScopeOnly Positional is "1234". args.Id == ptr(1234), scope.Organization is the default.
Test_runAdd_explicitOrg Positional is "myorg/1234". args.Id == ptr(1234), scope.Organization is "myorg".
Test_runAdd_APIError Mock UpdateWorkItem returns errors.New("boom"). Returns wrapped error including the source ID.
Test_runAdd_success_JSON --json flag set. Exporter receives the raw *WorkItem from the post-PATCH GetWorkItem.
Test_runAdd_tableOutput Default output, mock returns a work item with 2 relations. Table has 2 rows with the 2 columns populated; TYPE is the friendly name.
Test_runAdd_emptyRelations Work item has relations = nil. Table has 0 rows; no error.
Test_runAdd_caseInsensitiveRelationType --relation-type PARENT (uppercase). Resolves to System.LinkTypes.Hierarchy-Reverse exactly as --relation-type parent does.
Test_runAdd_patchesInInputOrder Targets supplied as 4,2,3 (single flag, comma-separated). Three ops in order [4, 2, 3].

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions