Skip to content
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
2 changes: 1 addition & 1 deletion docs/TOOLSET.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ Lists pull requests by commit IDs to find which pull requests contain specific c
Get a pull request by its ID.

- **Required**: `repositoryId`, `pullRequestId`
- **Optional**: `includeWorkItemRefs`
- **Optional**: `project`, `includeWorkItemRefs`, `includeLabels`, `includeChangedFiles`

### mcp_ado_repo_get_pull_request_changes

Expand Down
61 changes: 48 additions & 13 deletions src/tools/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,13 +1010,16 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri
project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."),
includeLabels: z.boolean().optional().default(false).describe("Whether to include a summary of labels in the response."),
includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of files changed in the pull request."),
},
async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels }) => {
async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles }) => {
try {
const connection = await connectionProvider();
const gitApi = await connection.getGitApi();
const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);

let enhancedResponse: Record<string, unknown> = { ...pullRequest };

if (includeLabels) {
try {
const projectId = pullRequest.repository?.project?.id;
Expand All @@ -1025,32 +1028,64 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri

const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined);

const enhancedResponse = {
...pullRequest,
enhancedResponse = {
...enhancedResponse,
labelSummary: {
labels: labelNames,
labelCount: labelNames.length,
},
};

return {
content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
};
} catch (error) {
console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`);
// Fall back to the original response without labels
const enhancedResponse = {
...pullRequest,
enhancedResponse = {
...enhancedResponse,
labelSummary: {},
};
}
}

return {
content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
if (includeChangedFiles) {
try {
const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);

if (iterations?.length) {
const latestIteration = iterations[iterations.length - 1];

if (latestIteration.id != null) {
const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);

enhancedResponse = {
...enhancedResponse,
changedFilesSummary: {
changeEntries: changes?.changeEntries ?? [],
fileCount: changes?.changeEntries?.length ?? 0,
nextSkip: changes?.nextSkip,
nextTop: changes?.nextTop,
},
};
} else {
enhancedResponse = {
...enhancedResponse,
changedFilesSummary: { changeEntries: [], fileCount: 0 },
};
}
} else {
enhancedResponse = {
...enhancedResponse,
changedFilesSummary: { changeEntries: [], fileCount: 0 },
};
}
} catch (error) {
console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`);
enhancedResponse = {
...enhancedResponse,
changedFilesSummary: {},
};
}
}

return {
content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }],
content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
Expand Down
162 changes: 162 additions & 0 deletions test/src/tools/repositories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3862,6 +3862,168 @@ describe("repos tools", () => {

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

it("should include changed files when includeChangedFiles is true", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id);
if (!call) throw new Error("repo_get_pull_request_by_id tool not registered");
const [, , , handler] = call;

const mockPR = {
pullRequestId: 123,
title: "Test PR",
repository: { project: { id: "project123", name: "testproject" } },
};
mockGitApi.getPullRequest.mockResolvedValue(mockPR);

const mockChangeEntries = [
{ changeTrackingId: 1, item: { path: "/src/file1.ts" }, changeType: 2 },
{ changeTrackingId: 2, item: { path: "/src/file2.ts" }, changeType: 1 },
];
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }, { id: 2 }]);
mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: mockChangeEntries });

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

const result = await handler(params);

expect(mockGitApi.getPullRequestIterations).toHaveBeenCalledWith("repo123", 123, undefined);
expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("repo123", 123, 2, undefined);

const resultData = JSON.parse(result.content[0].text);
expect(resultData.changedFilesSummary).toEqual({
changeEntries: mockChangeEntries,
fileCount: 2,
});
});

it("should not fetch changed files when includeChangedFiles is false", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockPR = { pullRequestId: 123, title: "Test PR" };
mockGitApi.getPullRequest.mockResolvedValue(mockPR);

const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: false });

expect(mockGitApi.getPullRequestIterations).not.toHaveBeenCalled();
expect(result.content[0].text).toBe(JSON.stringify(mockPR, null, 2));
});

it("should not fetch changed files when includeChangedFiles is not specified", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockPR = { pullRequestId: 123, title: "Test PR" };
mockGitApi.getPullRequest.mockResolvedValue(mockPR);

const result = await handler({ repositoryId: "repo123", pullRequestId: 123 });

expect(mockGitApi.getPullRequestIterations).not.toHaveBeenCalled();
expect(result.content[0].text).toBe(JSON.stringify(mockPR, null, 2));
});

it("should handle empty iterations when includeChangedFiles is true", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockPR = { pullRequestId: 123, title: "Test PR" };
mockGitApi.getPullRequest.mockResolvedValue(mockPR);
mockGitApi.getPullRequestIterations.mockResolvedValue([]);

const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true });

const resultData = JSON.parse(result.content[0].text);
expect(resultData.changedFilesSummary).toEqual({ changeEntries: [], fileCount: 0 });
expect(mockGitApi.getPullRequestIterationChanges).not.toHaveBeenCalled();
});

it("should handle getPullRequestIterationChanges API error gracefully", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockPR = { pullRequestId: 123, title: "Test PR" };
mockGitApi.getPullRequest.mockResolvedValue(mockPR);
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }]);
mockGitApi.getPullRequestIterationChanges.mockRejectedValue(new Error("API Error: Changes not accessible"));

const consoleSpy = jest.spyOn(console, "warn").mockImplementation();

const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true });

expect(consoleSpy).toHaveBeenCalledWith("Error fetching PR changed files: API Error: Changes not accessible");

const resultData = JSON.parse(result.content[0].text);
expect(resultData.pullRequestId).toBe(123);
expect(resultData.changedFilesSummary).toEqual({});

consoleSpy.mockRestore();
});

it("should handle iteration with null id when includeChangedFiles is true", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockPR = { pullRequestId: 123, title: "Test PR" };
mockGitApi.getPullRequest.mockResolvedValue(mockPR);
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: null }]);

const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true });

const resultData = JSON.parse(result.content[0].text);
expect(resultData.changedFilesSummary).toEqual({ changeEntries: [], fileCount: 0 });
expect(mockGitApi.getPullRequestIterationChanges).not.toHaveBeenCalled();
});

it("should work with both includeLabels and includeChangedFiles enabled", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockPR = {
pullRequestId: 123,
title: "Test PR",
repository: { project: { id: "project123", name: "testproject" } },
};
mockGitApi.getPullRequest.mockResolvedValue(mockPR);

const mockLabels = [{ name: "bug", id: "label1" }];
mockGitApi.getPullRequestLabels.mockResolvedValue(mockLabels);

const mockChangeEntries = [{ changeTrackingId: 1, item: { path: "/src/app.ts" }, changeType: 2 }];
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }]);
mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: mockChangeEntries });

const result = await handler({
repositoryId: "repo123",
pullRequestId: 123,
includeLabels: true,
includeChangedFiles: true,
});

expect(mockGitApi.getPullRequestLabels).toHaveBeenCalled();
expect(mockGitApi.getPullRequestIterations).toHaveBeenCalled();

const resultData = JSON.parse(result.content[0].text);
expect(resultData.labelSummary).toEqual({ labels: ["bug"], labelCount: 1 });
expect(resultData.changedFilesSummary).toEqual({ changeEntries: mockChangeEntries, fileCount: 1 });
});
});

describe("repo_get_pull_request_changes", () => {
Expand Down
Loading