Skip to content

Commit 78a1e36

Browse files
authored
Add pull request update tool (#361)
- Add repo_update_pull_request tool for updating PR title, description, draft status, and target branch - Update documentation to reflect new tool capabilities - Add validation to ensure at least one field is provided for update - Support partial updates with optional parameters ## GitHub issue number [322](#322) ## **Associated Risks** - May cause confusion with similar `update_pull_request_status` and `update_pull_request_reviewers`, neither of those operations can be accomplished with the underlying API ## ✅ **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?** - Been using it for a little over a week for personal uses. - src/tools/repos.test.ts (some very loose unit tests I let copilot author)
1 parent 62458e4 commit 78a1e36

File tree

3 files changed

+261
-8
lines changed

3 files changed

+261
-8
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Interact with these Azure DevOps services:
9595
- **repo_get_pull_request_by_id**: Get a pull request by its ID.
9696
- **repo_create_pull_request**: Create a new pull request.
9797
- **repo_update_pull_request_status**: Update the status of an existing pull request to active or abandoned.
98+
- **repo_update_pull_request**: Update various fields of an existing pull request (title, description, draft status, target branch).
9899
- **repo_update_pull_request_reviewers**: Add or remove reviewers for an existing pull request.
99100
- **repo_reply_to_comment**: Replies to a specific comment on a pull request.
100101
- **repo_resolve_comment**: Resolves a specific comment thread on a pull request.

src/tools/repos.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const REPO_TOOLS = {
3434
get_branch_by_name: "repo_get_branch_by_name",
3535
get_pull_request_by_id: "repo_get_pull_request_by_id",
3636
create_pull_request: "repo_create_pull_request",
37-
update_pull_request_status: "repo_update_pull_request_status",
37+
update_pull_request: "repo_update_pull_request",
3838
update_pull_request_reviewers: "repo_update_pull_request_reviewers",
3939
reply_to_comment: "repo_reply_to_comment",
4040
create_pull_request_thread: "repo_create_pull_request_thread",
@@ -144,19 +144,46 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
144144
);
145145

146146
server.tool(
147-
REPO_TOOLS.update_pull_request_status,
148-
"Update status of an existing pull request to active or abandoned.",
147+
REPO_TOOLS.update_pull_request,
148+
"Update a Pull Request by ID with specified fields.",
149149
{
150150
repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
151-
pullRequestId: z.number().describe("The ID of the pull request to be published."),
152-
status: z.enum(["Active", "Abandoned"]).describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."),
151+
pullRequestId: z.number().describe("The ID of the pull request to update."),
152+
title: z.string().optional().describe("The new title for the pull request."),
153+
description: z.string().optional().describe("The new description for the pull request."),
154+
isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."),
155+
targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."),
156+
status: z.enum(["Active", "Abandoned"]).optional().describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."),
153157
},
154-
async ({ repositoryId, pullRequestId, status }) => {
158+
async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status }) => {
155159
const connection = await connectionProvider();
156160
const gitApi = await connection.getGitApi();
157-
const statusValue = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
158161

159-
const updatedPullRequest = await gitApi.updatePullRequest({ status: statusValue }, repositoryId, pullRequestId);
162+
// Build update object with only provided fields
163+
const updateRequest: {
164+
title?: string;
165+
description?: string;
166+
isDraft?: boolean;
167+
targetRefName?: string;
168+
status?: number;
169+
} = {};
170+
if (title !== undefined) updateRequest.title = title;
171+
if (description !== undefined) updateRequest.description = description;
172+
if (isDraft !== undefined) updateRequest.isDraft = isDraft;
173+
if (targetRefName !== undefined) updateRequest.targetRefName = targetRefName;
174+
if (status !== undefined) {
175+
updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
176+
}
177+
178+
// Validate that at least one field is provided for update
179+
if (Object.keys(updateRequest).length === 0) {
180+
return {
181+
content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, or status) must be provided for update." }],
182+
isError: true,
183+
};
184+
}
185+
186+
const updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId);
160187

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

test/src/tools/repos.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5+
import { AccessToken } from "@azure/identity";
6+
import { WebApi } from "azure-devops-node-api";
7+
import { configureRepoTools, REPO_TOOLS } from "../../../src/tools/repos";
8+
9+
// Mock the auth module
10+
jest.mock("../../../src/tools/auth", () => ({
11+
getCurrentUserDetails: jest.fn(),
12+
}));
13+
14+
describe("repos tools", () => {
15+
let server: McpServer;
16+
let tokenProvider: jest.MockedFunction<() => Promise<AccessToken>>;
17+
let connectionProvider: jest.MockedFunction<() => Promise<WebApi>>;
18+
let mockGitApi: {
19+
updatePullRequest: jest.MockedFunction<(...args: unknown[]) => Promise<unknown>>;
20+
};
21+
22+
beforeEach(() => {
23+
server = {
24+
tool: jest.fn(),
25+
} as unknown as McpServer;
26+
27+
tokenProvider = jest.fn();
28+
mockGitApi = {
29+
updatePullRequest: jest.fn(),
30+
};
31+
32+
connectionProvider = jest.fn().mockResolvedValue({
33+
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
34+
});
35+
});
36+
37+
describe("repo_update_pull_request", () => {
38+
it("should update pull request with all provided fields", async () => {
39+
configureRepoTools(server, tokenProvider, connectionProvider);
40+
41+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);
42+
43+
if (!call) throw new Error("repo_update_pull_request tool not registered");
44+
const [, , , handler] = call;
45+
46+
const mockUpdatedPR = {
47+
pullRequestId: 123,
48+
title: "Updated Title",
49+
description: "Updated Description",
50+
isDraft: true,
51+
};
52+
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);
53+
54+
const params = {
55+
repositoryId: "repo123",
56+
pullRequestId: 123,
57+
title: "Updated Title",
58+
description: "Updated Description",
59+
isDraft: true,
60+
targetRefName: "refs/heads/main",
61+
};
62+
63+
const result = await handler(params);
64+
65+
expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
66+
{
67+
title: "Updated Title",
68+
description: "Updated Description",
69+
isDraft: true,
70+
targetRefName: "refs/heads/main",
71+
},
72+
"repo123",
73+
123
74+
);
75+
76+
expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
77+
});
78+
79+
it("should update pull request with only title", async () => {
80+
configureRepoTools(server, tokenProvider, connectionProvider);
81+
82+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);
83+
84+
if (!call) throw new Error("repo_update_pull_request tool not registered");
85+
const [, , , handler] = call;
86+
87+
const mockUpdatedPR = { pullRequestId: 123, title: "New Title" };
88+
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);
89+
90+
const params = {
91+
repositoryId: "repo123",
92+
pullRequestId: 123,
93+
title: "New Title",
94+
};
95+
96+
const result = await handler(params);
97+
98+
expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
99+
{
100+
title: "New Title",
101+
},
102+
"repo123",
103+
123
104+
);
105+
106+
expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
107+
});
108+
109+
it("should update pull request status to Active", async () => {
110+
configureRepoTools(server, tokenProvider, connectionProvider);
111+
112+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);
113+
114+
if (!call) throw new Error("repo_update_pull_request tool not registered");
115+
const [, , , handler] = call;
116+
117+
const mockUpdatedPR = { pullRequestId: 123, status: 1 }; // Active status
118+
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);
119+
120+
const params = {
121+
repositoryId: "repo123",
122+
pullRequestId: 123,
123+
status: "Active" as const,
124+
};
125+
126+
const result = await handler(params);
127+
128+
expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
129+
{
130+
status: 1, // PullRequestStatus.Active
131+
},
132+
"repo123",
133+
123
134+
);
135+
136+
expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
137+
});
138+
139+
it("should update pull request status to Abandoned", async () => {
140+
configureRepoTools(server, tokenProvider, connectionProvider);
141+
142+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);
143+
144+
if (!call) throw new Error("repo_update_pull_request tool not registered");
145+
const [, , , handler] = call;
146+
147+
const mockUpdatedPR = { pullRequestId: 123, status: 2 }; // Abandoned status
148+
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);
149+
150+
const params = {
151+
repositoryId: "repo123",
152+
pullRequestId: 123,
153+
status: "Abandoned" as const,
154+
};
155+
156+
const result = await handler(params);
157+
158+
expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
159+
{
160+
status: 2, // PullRequestStatus.Abandoned
161+
},
162+
"repo123",
163+
123
164+
);
165+
166+
expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
167+
});
168+
169+
it("should update pull request with status and other fields", async () => {
170+
configureRepoTools(server, tokenProvider, connectionProvider);
171+
172+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);
173+
174+
if (!call) throw new Error("repo_update_pull_request tool not registered");
175+
const [, , , handler] = call;
176+
177+
const mockUpdatedPR = {
178+
pullRequestId: 123,
179+
title: "Updated Title",
180+
status: 1,
181+
};
182+
mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR);
183+
184+
const params = {
185+
repositoryId: "repo123",
186+
pullRequestId: 123,
187+
title: "Updated Title",
188+
status: "Active" as const,
189+
};
190+
191+
const result = await handler(params);
192+
193+
expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith(
194+
{
195+
title: "Updated Title",
196+
status: 1, // PullRequestStatus.Active
197+
},
198+
"repo123",
199+
123
200+
);
201+
202+
expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2));
203+
});
204+
205+
it("should return error when no fields provided", async () => {
206+
configureRepoTools(server, tokenProvider, connectionProvider);
207+
208+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request);
209+
210+
if (!call) throw new Error("repo_update_pull_request tool not registered");
211+
const [, , , handler] = call;
212+
213+
const params = {
214+
repositoryId: "repo123",
215+
pullRequestId: 123,
216+
};
217+
218+
const result = await handler(params);
219+
220+
expect(mockGitApi.updatePullRequest).not.toHaveBeenCalled();
221+
expect(result.isError).toBe(true);
222+
expect(result.content[0].text).toContain("At least one field (title, description, isDraft, targetRefName, or status) must be provided for update.");
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)