Skip to content

Commit 62458e4

Browse files
danhellemCopilot
andauthored
Adding work_item_unlink to remove links from a work item (#365)
Added tool and tests to remove a link from a work item in batch. Meaning you can remove several links at once. ## GitHub issue number #132 ## **Associated Risks** N/A ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Added all new automated tests and tested manually. Will post video and prompts in follow-up commits or PR --------- Co-authored-by: Copilot <[email protected]>
1 parent 2bb94eb commit 62458e4

File tree

4 files changed

+495
-1
lines changed

4 files changed

+495
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Interact with these Azure DevOps services:
7373
- **wit_get_query_results_by_id**: Retrieve the results of a work item query given the query ID.
7474
- **wit_update_work_items_batch**: Update work items in batch.
7575
- **wit_work_items_link**: Link work items together in batch.
76+
- **wit_work_item_unlink**: Unlink one or many links from a work item.
7677

7778
#### Deprecated Tools
7879

docs/EXAMPLES.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This guide offers step-by-step examples for using the Azure DevOps MCP Server to
1010
[Create and Link Test Cases](#create-and-link-test-cases)<br/>
1111
[Triage Work](#triage-work)<br/>
1212
[Using Markdown Format](#adding-and-updating-work-items-using-the-format-paramater)<br/>
13+
[Remove Links from a Work Item](#remove-one-or-more-links-from-a-work-item)
1314

1415
## 🙋‍♂️ Projects and Teams
1516

@@ -132,3 +133,19 @@ Update work item 12345 with a new description and use Markdown text. Use Markdow
132133
```
133134

134135
📽️ [Azure DevOps MCP Server: Using Markdown format for create and update work items](https://youtu.be/OD4c2m7Fj9U)
136+
137+
### Remove One or More Links from a Work Item
138+
139+
Use this tool to remove one or more links from a work item, either by specifying individual links or by link type.
140+
141+
First, retrieve the work item whose links you want to remove:
142+
143+
```plaintext
144+
Get work item 1234 in Contoso project and show me the relations
145+
```
146+
147+
Next, remove a specific link to a work item, pull request, etc. or remove links by type (for example, "related"):
148+
149+
```plaintext
150+
Remove link 5678 and 91011 from work item 1234. Also remove any related links and links to pull request 121314
151+
```

src/tools/workitems.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { AccessToken } from "@azure/identity";
55
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { WebApi } from "azure-devops-node-api";
7-
import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
7+
import { WorkItemExpand, WorkItemRelation } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
88
import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
99
import { z } from "zod";
1010
import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
@@ -27,6 +27,7 @@ const WORKITEM_TOOLS = {
2727
get_query_results_by_id: "wit_get_query_results_by_id",
2828
update_work_items_batch: "wit_update_work_items_batch",
2929
work_items_link: "wit_work_items_link",
30+
work_item_unlink: "wit_work_item_unlink",
3031
};
3132

3233
function getLinkTypeFromName(name: string) {
@@ -53,6 +54,8 @@ function getLinkTypeFromName(name: string) {
5354
return "Microsoft.VSTS.Common.Affects-Forward";
5455
case "affected by":
5556
return "Microsoft.VSTS.Common.Affects-Reverse";
57+
case "artifact":
58+
return "ArtifactLink";
5659
default:
5760
throw new Error(`Unknown link type: ${name}`);
5861
}
@@ -771,6 +774,85 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
771774
};
772775
}
773776
);
777+
778+
server.tool(
779+
WORKITEM_TOOLS.work_item_unlink,
780+
"Remove one or many links from a single work item",
781+
{
782+
project: z.string().describe("The name or ID of the Azure DevOps project."),
783+
id: z.number().describe("The ID of the work item to remove the links from."),
784+
type: z
785+
.enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"])
786+
.default("related")
787+
.describe(
788+
"Type of link to remove. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', 'affected by', and 'artifact'. Defaults to 'related'."
789+
),
790+
url: z.string().optional().describe("Optional URL to match for the link to remove. If not provided, all links of the specified type will be removed."),
791+
},
792+
async ({ project, id, type, url }) => {
793+
try {
794+
const connection = await connectionProvider();
795+
const workItemApi = await connection.getWorkItemTrackingApi();
796+
const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, project);
797+
const relations: WorkItemRelation[] = workItem.relations ?? [];
798+
const linkType = getLinkTypeFromName(type);
799+
800+
let relationIndexes: number[] = [];
801+
802+
if (url && url.trim().length > 0) {
803+
// If url is provided, find relations matching both rel type and url
804+
relationIndexes = relations.map((relation, idx) => (relation.url === url ? idx : -1)).filter((idx) => idx !== -1);
805+
} else {
806+
// If url is not provided, find all relations matching rel type
807+
relationIndexes = relations.map((relation, idx) => (relation.rel === linkType ? idx : -1)).filter((idx) => idx !== -1);
808+
}
809+
810+
if (relationIndexes.length === 0) {
811+
return {
812+
content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }],
813+
isError: true,
814+
};
815+
}
816+
817+
// Get the relations that will be removed for logging
818+
const removedRelations = relationIndexes.map((idx) => relations[idx]);
819+
820+
// Sort indexes in descending order to avoid index shifting when removing
821+
relationIndexes.sort((a, b) => b - a);
822+
823+
const apiUpdates = relationIndexes.map((idx) => ({
824+
op: "remove",
825+
path: `/relations/${idx}`,
826+
}));
827+
828+
const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, project);
829+
830+
return {
831+
content: [
832+
{
833+
type: "text",
834+
text:
835+
`Removed ${removedRelations.length} link(s) of type '${type}':\n` +
836+
JSON.stringify(removedRelations, null, 2) +
837+
`\n\nUpdated work item result:\n` +
838+
JSON.stringify(updatedWorkItem, null, 2),
839+
},
840+
],
841+
isError: false,
842+
};
843+
} catch (error) {
844+
return {
845+
content: [
846+
{
847+
type: "text",
848+
text: `Error unlinking work item: ${error instanceof Error ? error.message : "Unknown error occurred"}`,
849+
},
850+
],
851+
isError: true,
852+
};
853+
}
854+
}
855+
);
774856
}
775857

776858
export { WORKITEM_TOOLS, configureWorkItemTools };

0 commit comments

Comments
 (0)