Fixing Link Types in Bulk in Azure Boards (Azure DevOps)

I’ve had a few customers over the years that have needed to bulk change link types in Azure Boards (i.e. change the types of relationship between two work items, but at scale). How does someone get into such a situation?

Several reasons:

  • Performed bulk imports of work items and the work item hierarchy was misconfigured.
  • Performed bulk imports of work items and test cases (to be fair, also work items) which don’t recognize “tested/tested by” link types (only “parent/child”).
  • Someone on the team was incorrectly creating links (maybe creating “related” links instead of “dependent”), and now it’s a pain to undo that.
  • I’m sure there are more.

It’s typically the first or second one – an unexpected result from a work item import (usually from Excel). The import work items function only creates parent/child links, so if you’re trying to import test cases (for example), they won’t be recognized as tests that cover the user stories.

Like this:

Sample spreadsheet for work items to import into ADO.
Sample spreadsheet of work items to be imported into Azure DevOps

If you import this spreadsheet, you get test cases that are “children” of the user stories. Not desirable.

Whatever the reason, misconfigured links in Azure Boards can created unintended experiences (tasks not showing up on task boards, stories not properly rolling up to epics, etc.)

For some organizations, they can just assign someone to walk through all the work items with links that need to be redone, delete the link, and create a new one with the appropriate link type. But that can be incredibly time-consuming. That said, there is a newly released (Q4 2022) feature that allows a user to simply change the link type (rather than delete & re-create), but that’s still a one-by-one process. If you’ve just imported 2000 work items, that could be hundreds of links to change.

Programmatically Changing Link Types

I figured there’s got to be a way to make these bulk changes in a more automated way. So, I dove into the Azure DevOps SDK and wrote a simple console app to accomplish this.

You can see the full example on GitHub here

You effectively set the following values in the config.json file (to be passed as the only argument to the console application):

{
  "orgName": "<org_name>",
  "projectName": "<project name>",
  "sourceWorkItemType": "<source work item type>",
  "targetWorkItemType": "<target work item type>",
  "sourceLinkType": "<link type to look for>",
  "targetLinkType": "<link type to replace with>",
  "personalAccessToken": "<pat>"
}

More on link types: Link types reference guide – Azure Boards | Microsoft Learn

For example, if you specify:

{
  "orgName": "MyKillerOrg",
  "projectName": "My Killer Project",
  "sourceWorkItemType": "User Story",
  "targetWorkItemType": "Test Case",
  "sourceLinkType": "System.LinkTypes.Hierarchy-Forward",
  "targetLinkType": "Microsoft.VSTS.Common.TestedBy-Forward",
  "personalAccessToken": "<your pat here>"
}

This means you want the app to find all user stories that have parent-child links to test cases and change them to have tests/tested by links.

If you know me or have seen my LinkedIn profile, you know I’m not a professional developer – so you don’t get to make fun of my code. But it works on my machine!

You can see the full example on GitHub here.

Let’s take a look at the important parts:

In LinkWorker.cs:

The ProcessWorkItems method connects to Azure DevOps, builds and runs a WIQL query to get the work items.

public List<WorkItem> QueryWorkItems()
        {
            // create a wiql object and build our query
            var wiql = new Wiql()
            {
                Query = "Select [Id] " +
                        "From WorkItems " +
                        "Where [Work Item Type] = '" + sourceWIT + "' " +
                        "And [System.TeamProject] = '" + projectName + "' " +
                        "Order By [Id] Asc",
            };
            // execute the query to get the list of work items in the results
            var result = witClient.QueryByWiqlAsync(wiql).Result;
            var ids = result.WorkItems.Select(item => item.Id).ToArray();

            // get work items for the ids found in query
            return witClient.GetWorkItemsAsync(ids, expand: WorkItemExpand.Relations).Result;
        }

It then finds which of the returned work items have linked items that meet the criteria.

 // loop though work items
            foreach (var wi in workItems)
            {
                Console.WriteLine("{0}: {1}", wi.Id, wi.Fields["System.Title"]);
                targetRelationIndexes = new();
                targetWorkItemIds = new();

                if (wi.Relations != null) 
                {
                    WorkItemRelation rel;
                    for (int i = 0; i < wi.Relations.Count; i++)
                    {
                        rel = wi.Relations[i];
                        if (rel.Rel == sourceLinkType) 
                        {
                            var linkedItem = witClient.GetWorkItemAsync(GetWorkItemIdFromUrl(rel.Url)).Result;
                            if (linkedItem.Fields["System.WorkItemType"].ToString() == targetWIT)
                            {
                                targetRelationIndexes.Add(i);
                                targetWorkItemIds.Add(Convert.ToInt32(linkedItem.Id));
                            }
                        }
                    }
                }
                if (targetRelationIndexes.Count > 0)
                {
                    Console.WriteLine("\tFound {0} links to update.", targetRelationIndexes.Count);
                    // Remove current links
                    BulkRemoveSourceRelationships(Convert.ToInt32(wi.Id), targetRelationIndexes);
                    // Add new links
                    BulkAddTargetLinks(Convert.ToInt32(wi.Id), targetWorkItemIds);
                }
                else
                {
                    Console.WriteLine("\tNo links found to update.");
                }
            }

It’s worth noting that we need to keep a list of link indexes AND work item IDs, as you need the former to remove the existing link (uses the index of the linked relationship for reference) and the latter to create the new link with the desired link type.

For each work item relationship that meets the criteria, BulkRemoveSourceRelationships is called to delete the existing relationship. Subsequently, BulkAddTargetLinks creates a new relationship between the 2 work items with the specified (correct) link type. Each method uses the PatchDocument approach.

WorkItem BulkRemoveSourceRelationships(int sourceId, List<int> indexes)
        {
            JsonPatchDocument patchDocument = new JsonPatchDocument();
            foreach (int index in indexes)
            {
                patchDocument.Add(
                    new JsonPatchOperation()
                    {
                        Operation = Operation.Remove,
                        Path = string.Format("/relations/{0}", index)
                    }
                );
            }
            return witClient.UpdateWorkItemAsync(patchDocument, sourceId).Result;
        }
 WorkItem BulkAddTargetLinks(int sourceId, List<int> targetIds)
        {
            WorkItem targetItem;
            JsonPatchDocument patchDocument = new JsonPatchDocument();
            foreach (int id in targetIds)
            {
                targetItem = witClient.GetWorkItemAsync(id).Result;
                patchDocument.Add(
                   new JsonPatchOperation()
                   {
                       Operation = Operation.Add,
                       Path = "/relations/-",
                       Value = new
                       {
                           rel = targetLinkType,
                           url = targetItem.Url,
                           attributes = new
                           {
                               comment = "Making a new link for tested/tested by"
                           }
                       }
                   }
                );
            }
            return witClient.UpdateWorkItemAsync(patchDocument, sourceId).Result;
        }

That’s really about it. In my test against roughly 100 work items, it took about 15 seconds to run.

Again you can see the full (SAMPLE) code in the GitHub repo (yes, it’s called GoofyLittleLinkChanger).

Questions? Thoughts? Thanks for reading!