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 9574e993..ffa6aee8 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) { @@ -858,6 +859,160 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< } } ); + + server.tool( + WORKITEM_TOOLS.add_artifact_link, + "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."), + + // 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", + "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'. 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, 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 = [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: finalArtifactUri, + 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: finalArtifactUri, + 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 eb7d74d7..c8ec4f5b 100644 --- a/test/src/tools/workitems.test.ts +++ b/test/src/tools/workitems.test.ts @@ -2318,4 +2318,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); + }); + }); + }); });