Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Interact with these Azure DevOps services:
- **repo_get_pull_request_by_id**: Get a pull request by its ID.
- **repo_create_pull_request**: Create a new pull request.
- **repo_update_pull_request_status**: Update the status of an existing pull request to active or abandoned.
- **repo_update_pull_request**: Update various fields of an existing pull request (title, description, draft status, target branch).
- **repo_update_pull_request_reviewers**: Add or remove reviewers for an existing pull request.
- **repo_reply_to_comment**: Replies to a specific comment on a pull request.
- **repo_resolve_comment**: Resolves a specific comment thread on a pull request.
Expand Down
44 changes: 44 additions & 0 deletions src/tools/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const REPO_TOOLS = {
get_pull_request_by_id: "repo_get_pull_request_by_id",
create_pull_request: "repo_create_pull_request",
update_pull_request_status: "repo_update_pull_request_status",
update_pull_request: "repo_update_pull_request",
update_pull_request_reviewers: "repo_update_pull_request_reviewers",
reply_to_comment: "repo_reply_to_comment",
create_pull_request_thread: "repo_create_pull_request_thread",
Expand Down Expand Up @@ -163,6 +164,49 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
}
);

server.tool(
REPO_TOOLS.update_pull_request,
"Update various fields of an existing pull request (title, description, draft status, target branch).",
{
repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
pullRequestId: z.number().describe("The ID of the pull request to update."),
title: z.string().optional().describe("The new title for the pull request."),
description: z.string().optional().describe("The new description for the pull request."),
isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."),
targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also add an optional parameter for status in this tool, and then remove the update_pull_request_status tool - they use the same node API, and it's better for the LLM to keep the number of tools down.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. The description of the tool was a result of me trying to hint the model not to attempt to use this for status. I've removed update_pull_request_status and made the description align with the one used for update_work_item.

},
async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName }) => {
const connection = await connectionProvider();
const gitApi = await connection.getGitApi();

// Build update object with only provided fields
const updateRequest: {
title?: string;
description?: string;
isDraft?: boolean;
targetRefName?: string;
} = {};
if (title !== undefined) updateRequest.title = title;
if (description !== undefined) updateRequest.description = description;
if (isDraft !== undefined) updateRequest.isDraft = isDraft;
if (targetRefName !== undefined) updateRequest.targetRefName = targetRefName;

// Validate that at least one field is provided for update
if (Object.keys(updateRequest).length === 0) {
return {
content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, or targetRefName) must be provided for update." }],
isError: true,
};
}

const updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId);

return {
content: [{ type: "text", text: JSON.stringify(updatedPullRequest, null, 2) }],
};
}
);

server.tool(
REPO_TOOLS.update_pull_request_reviewers,
"Add or remove reviewers for an existing pull request.",
Expand Down
129 changes: 129 additions & 0 deletions test/src/tools/repos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { AccessToken } from "@azure/identity";
import { WebApi } from "azure-devops-node-api";
import { configureRepoTools, REPO_TOOLS } from "../../../src/tools/repos";

// Mock the auth module
jest.mock("../../../src/tools/auth", () => ({
getCurrentUserDetails: jest.fn(),
}));

describe("repos tools", () => {
let server: McpServer;
let tokenProvider: jest.MockedFunction<() => Promise<AccessToken>>;
let connectionProvider: jest.MockedFunction<() => Promise<WebApi>>;
let mockGitApi: {
updatePullRequest: jest.MockedFunction<(...args: unknown[]) => Promise<unknown>>;
};

beforeEach(() => {
server = {
tool: jest.fn(),
} as unknown as McpServer;

tokenProvider = jest.fn();
mockGitApi = {
updatePullRequest: jest.fn(),
};

connectionProvider = jest.fn().mockResolvedValue({
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
});
});

describe("repo_update_pull_request", () => {
it("should update pull request with all provided fields", async () => {
configureRepoTools(server, tokenProvider, connectionProvider);

const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);

if (!call) throw new Error("repo_update_pull_request tool not registered");
const [, , , handler] = call;

const mockUpdatedPR = {
pullRequestId: 123,
title: "Updated Title",
description: "Updated Description",
isDraft: true,
};
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);

const params = {
repositoryId: "repo123",
pullRequestId: 123,
title: "Updated Title",
description: "Updated Description",
isDraft: true,
targetRefName: "refs/heads/main",
};

const result = await handler(params);

expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
{
title: "Updated Title",
description: "Updated Description",
isDraft: true,
targetRefName: "refs/heads/main",
},
"repo123",
123
);

expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
});

it("should update pull request with only title", async () => {
configureRepoTools(server, tokenProvider, connectionProvider);

const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);

if (!call) throw new Error("repo_update_pull_request tool not registered");
const [, , , handler] = call;

const mockUpdatedPR = { pullRequestId: 123, title: "New Title" };
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);

const params = {
repositoryId: "repo123",
pullRequestId: 123,
title: "New Title",
};

const result = await handler(params);

expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
{
title: "New Title",
},
"repo123",
123
);

expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
});

it("should return error when no fields provided", async () => {
configureRepoTools(server, tokenProvider, connectionProvider);

const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);

if (!call) throw new Error("repo_update_pull_request tool not registered");
const [, , , handler] = call;

const params = {
repositoryId: "repo123",
pullRequestId: 123,
};

const result = await handler(params);

expect(mockGitApi.updatePullRequest).not.toHaveBeenCalled();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("At least one field");
});
});
});
Loading