Skip to content

Add functionality to link artifacts to work items #377

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<br/>
[Triage Work](#triage-work)<br/>
[Using Markdown Format](#adding-and-updating-work-items-using-the-format-paramater)<br/>
[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)<br/>
[Adding Artifact Links](#adding-artifact-links)

## πŸ™‹β€β™‚οΈ Projects and Teams

Expand Down Expand Up @@ -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]()
155 changes: 155 additions & 0 deletions src/tools/workitems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
116 changes: 116 additions & 0 deletions test/src/tools/workitems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});
Loading