From de18da6a18cdf77df92a70fffe4f6ecef36537a8 Mon Sep 17 00:00:00 2001 From: Engin Polat Date: Thu, 31 Jul 2025 14:23:34 -0700 Subject: [PATCH 1/4] Add functionality to link artifacts to work items --- docs/EXAMPLES.md | 21 +++++- src/tools/workitems.ts | 68 ++++++++++++++++++ test/src/tools/workitems.test.ts | 116 +++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 484e6276..83d672a3 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -10,7 +10,8 @@ This guide offers step-by-step examples for using the Azure DevOps MCP Server to [Create and Link Test Cases](#create-and-link-test-cases)
[Triage Work](#triage-work)
[Using Markdown Format](#adding-and-updating-work-items-using-the-format-paramater)
-[Remove Links from a Work Item](#remove-one-or-more-links-from-a-work-item) +[Remove Links from a Work Item](#remove-one-or-more-links-from-a-work-item)
+[Adding Artifact Links](#adding-artifact-links) ## 🙋‍♂️ Projects and Teams @@ -149,3 +150,21 @@ Next, remove a specific link to a work item, pull request, etc. or remove links ```plaintext Remove link 5678 and 91011 from work item 1234. Also remove any related links and links to pull request 121314 ``` + +## 🔗 Adding Artifact Links + +### Add Artifact Links to Work Items + +Use this tool to link work items to repository artifacts like branches, commits, and pull requests. This is particularly useful for GitHub Copilot integration, which requires artifact links to understand repository context. + +First, you'll need the proper vstfs URI format for your artifact: + +- **Branch**: `vstfs:///Git/Ref/{projectId}%2F{repositoryId}%2FGB{branchName}` +- **Commit**: `vstfs:///Git/Commit/{projectId}%2F{repositoryId}%2F{commitId}` +- **Pull Request**: `vstfs:///Git/PullRequestId/{projectId}%2F{repositoryId}%2F{pullRequestId}` + +```plaintext +Add a branch artifact link to work item 1234 in project "Contoso" with URI "vstfs:///Git/Ref/12341234-1234-1234-1234-123412341234%2F12341234-1234-1234-1234-123412341234%2FGBmain" and link type "Branch" with comment "Linked to main branch for GitHub Copilot integration" +``` + +📽️ [Adding artifact links enables automation of work item creation and GitHub Copilot integration]() diff --git a/src/tools/workitems.ts b/src/tools/workitems.ts index 7e94c69c..895ef57b 100644 --- a/src/tools/workitems.ts +++ b/src/tools/workitems.ts @@ -28,6 +28,7 @@ const WORKITEM_TOOLS = { update_work_items_batch: "wit_update_work_items_batch", work_items_link: "wit_work_items_link", work_item_unlink: "wit_work_item_unlink", + add_artifact_link: "wit_add_artifact_link", }; function getLinkTypeFromName(name: string) { @@ -853,6 +854,73 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< } } ); + + server.tool( + WORKITEM_TOOLS.add_artifact_link, + "Add artifact links (repository, branch, commit) to work items.", + { + workItemId: z.number().describe("The ID of the work item to add the artifact link to."), + project: z.string().describe("The name or ID of the Azure DevOps project."), + artifactUri: z.string().describe("The URI of the artifact to link (e.g., vstfs:///Git/Ref/{projectId}%2F{repositoryId}%2FGB{branchName} for branches)."), + linkType: z.string().default("Branch").describe("Type of artifact link, defaults to 'Branch'."), + comment: z.string().optional().describe("Comment to include with the artifact link."), + }, + async ({ workItemId, project, artifactUri, linkType, comment }) => { + try { + const connection = await connectionProvider(); + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Create the patch document for adding an artifact link relation + const patchDocument = [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: artifactUri, + attributes: { + name: linkType, + ...(comment && { comment }), + }, + }, + }, + ]; + + // Use the WorkItem API to update the work item with the new relation + const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, project); + + if (!workItem) { + return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + workItemId, + artifactUri, + linkType, + comment: comment || null, + success: true, + }, + null, + 2 + ), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error adding artifact link to work item: ${errorMessage}` }], + isError: true, + }; + } + } + ); } export { WORKITEM_TOOLS, configureWorkItemTools }; diff --git a/test/src/tools/workitems.test.ts b/test/src/tools/workitems.test.ts index 2378cf28..fd7e991b 100644 --- a/test/src/tools/workitems.test.ts +++ b/test/src/tools/workitems.test.ts @@ -2250,4 +2250,120 @@ describe("configureWorkItemTools", () => { expect(result.isError).toBe(true); }); }); + + describe("artifact link tools", () => { + describe("wit_add_artifact_link", () => { + it("should add artifact link to work item successfully", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); + if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const [, , , handler] = call; + + const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; + mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); + + const params = { + workItemId: 1234, + project: "TestProject", + artifactUri: "vstfs:///Git/Ref/12341234-1234-1234-1234-123412341234%2F12341234-1234-1234-1234-123412341234%2FGBmain", + linkType: "Branch", + comment: "Linked to main branch", + }; + + const result = await handler(params); + + expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( + {}, + [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: "vstfs:///Git/Ref/12341234-1234-1234-1234-123412341234%2F12341234-1234-1234-1234-123412341234%2FGBmain", + attributes: { + name: "Branch", + comment: "Linked to main branch", + }, + }, + }, + ], + 1234, + "TestProject" + ); + + const response = JSON.parse(result.content[0].text); + expect(response.workItemId).toBe(1234); + expect(response.artifactUri).toBe("vstfs:///Git/Ref/12341234-1234-1234-1234-123412341234%2F12341234-1234-1234-1234-123412341234%2FGBmain"); + expect(response.linkType).toBe("Branch"); + expect(response.comment).toBe("Linked to main branch"); + expect(response.success).toBe(true); + }); + + it("should add artifact link without comment", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); + if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const [, , , handler] = call; + + const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; + mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); + + const params = { + workItemId: 1234, + project: "TestProject", + artifactUri: "vstfs:///Git/Commit/12341234-1234-1234-1234-123412341234%2F12341234-1234-1234-1234-123412341234%2Fabc123", + linkType: "Commit", + }; + + const result = await handler(params); + + expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( + {}, + [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: "vstfs:///Git/Commit/12341234-1234-1234-1234-123412341234%2F12341234-1234-1234-1234-123412341234%2Fabc123", + attributes: { + name: "Commit", + }, + }, + }, + ], + 1234, + "TestProject" + ); + + const response = JSON.parse(result.content[0].text); + expect(response.comment).toBe(null); + }); + + it("should handle errors when adding artifact link", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); + if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const [, , , handler] = call; + + mockWorkItemTrackingApi.updateWorkItem.mockRejectedValue(new Error("API Error")); + + const params = { + workItemId: 1234, + project: "TestProject", + artifactUri: "vstfs:///Git/Ref/invalid", + linkType: "Branch", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error adding artifact link to work item: API Error"); + expect(result.isError).toBe(true); + }); + }); + }); }); From 19a444a56b73353757b905d55b498f212ec545bb Mon Sep 17 00:00:00 2001 From: Engin Polat Date: Mon, 4 Aug 2025 23:07:49 +0000 Subject: [PATCH 2/4] enhance artifact link functionality by adding support for link Types as an enum --- src/tools/workitems.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/tools/workitems.ts b/src/tools/workitems.ts index 491326f3..17c63189 100644 --- a/src/tools/workitems.ts +++ b/src/tools/workitems.ts @@ -867,7 +867,24 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< workItemId: z.number().describe("The ID of the work item to add the artifact link to."), project: z.string().describe("The name or ID of the Azure DevOps project."), artifactUri: z.string().describe("The URI of the artifact to link (e.g., vstfs:///Git/Ref/{projectId}%2F{repositoryId}%2FGB{branchName} for branches)."), - linkType: z.string().default("Branch").describe("Type of artifact link, defaults to 'Branch'."), + linkType: z.enum([ + "Branch", + "Build", + "Fixed in Changeset", + "Fixed in Commit", + "Found in build", + "Integrated in build", + "Model Link", + "Pull Request", + "Related Workitem", + "Result Attachment", + "Source Code File", + "Tag", + "Test Result", + "Wiki", + ]) + .default("Branch") + .describe("Type of artifact link, defaults to 'Branch'."), comment: z.string().optional().describe("Comment to include with the artifact link."), }, async ({ workItemId, project, artifactUri, linkType, comment }) => { From fee8bf64e34e202ec69ad14fd0fcc3b5ff7e1049 Mon Sep 17 00:00:00 2001 From: Engin Polat Date: Mon, 4 Aug 2025 23:23:43 +0000 Subject: [PATCH 3/4] enhance add_artifact_link functionality to support additional artifact types and URI construction options --- src/tools/workitems.ts | 81 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/src/tools/workitems.ts b/src/tools/workitems.ts index 17c63189..bede0ea1 100644 --- a/src/tools/workitems.ts +++ b/src/tools/workitems.ts @@ -862,11 +862,22 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< server.tool( WORKITEM_TOOLS.add_artifact_link, - "Add artifact links (repository, branch, commit) to work items.", + "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically.", { workItemId: z.number().describe("The ID of the work item to add the artifact link to."), project: z.string().describe("The name or ID of the Azure DevOps project."), - artifactUri: z.string().describe("The URI of the artifact to link (e.g., vstfs:///Git/Ref/{projectId}%2F{repositoryId}%2FGB{branchName} for branches)."), + + // Option 1: Provide full URI directly + artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored."), + + // Option 2: Provide individual components to build URI automatically based on linkType + projectId: z.string().optional().describe("The project ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), + repositoryId: z.string().optional().describe("The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), + branchName: z.string().optional().describe("The branch name (e.g., 'main'). Required when linkType is 'Branch'."), + commitId: z.string().optional().describe("The commit SHA hash. Required when linkType is 'Fixed in Commit'."), + pullRequestId: z.number().optional().describe("The pull request ID. Required when linkType is 'Pull Request'."), + buildId: z.number().optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."), + linkType: z.enum([ "Branch", "Build", @@ -884,14 +895,72 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< "Wiki", ]) .default("Branch") - .describe("Type of artifact link, defaults to 'Branch'."), + .describe("Type of artifact link, defaults to 'Branch'. This determines both the link type and how to build the VSTFS URI from individual components."), comment: z.string().optional().describe("Comment to include with the artifact link."), }, - async ({ workItemId, project, artifactUri, linkType, comment }) => { + async ({ workItemId, project, artifactUri, projectId, repositoryId, branchName, commitId, pullRequestId, buildId, linkType, comment }) => { try { const connection = await connectionProvider(); const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + let finalArtifactUri: string; + + if (artifactUri) { + // Use the provided full URI + finalArtifactUri = artifactUri; + } else { + // Build the URI from individual components based on linkType + switch (linkType) { + case "Branch": + if (!projectId || !repositoryId || !branchName) { + return { + content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`; + break; + + case "Fixed in Commit": + if (!projectId || !repositoryId || !commitId) { + return { + content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`; + break; + + case "Pull Request": + if (!projectId || !repositoryId || pullRequestId === undefined) { + return { + content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`; + break; + + case "Build": + case "Found in build": + case "Integrated in build": + if (buildId === undefined) { + return { + content: [{ type: "text", text: `For '${linkType}' links, 'buildId' is required.` }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`; + break; + + default: + return { + content: [{ type: "text", text: `URI building from components is not supported for link type '${linkType}'. Please provide the full 'artifactUri' instead.` }], + isError: true, + }; + } + } + // Create the patch document for adding an artifact link relation const patchDocument = [ { @@ -899,7 +968,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< path: "/relations/-", value: { rel: "ArtifactLink", - url: artifactUri, + url: finalArtifactUri, attributes: { name: linkType, ...(comment && { comment }), @@ -922,7 +991,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< text: JSON.stringify( { workItemId, - artifactUri, + artifactUri: finalArtifactUri, linkType, comment: comment || null, success: true, From d3dfa7422563093cf51d5a4995213ccf57898df8 Mon Sep 17 00:00:00 2001 From: Engin Polat Date: Mon, 4 Aug 2025 23:26:38 +0000 Subject: [PATCH 4/4] fixing linter warnings --- src/tools/workitems.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/workitems.ts b/src/tools/workitems.ts index bede0ea1..3702b5a9 100644 --- a/src/tools/workitems.ts +++ b/src/tools/workitems.ts @@ -878,7 +878,8 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< pullRequestId: z.number().optional().describe("The pull request ID. Required when linkType is 'Pull Request'."), buildId: z.number().optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."), - linkType: z.enum([ + linkType: z + .enum([ "Branch", "Build", "Fixed in Changeset",