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
14 changes: 10 additions & 4 deletions src/tools/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,15 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri
project: z.string().optional().describe("Project ID or project name. Required for list_by_commits. Optional for get and list."),
includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to include work item references. Used for get."),
includeLabels: z.boolean().optional().default(false).describe("Whether to include labels. Used for get."),
includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of changed files. Used for get."),
top: z.coerce.number().default(100).describe("The maximum number of pull requests to return. Used for list. Defaults to 100."),
skip: z.coerce.number().default(0).describe("The number of pull requests to skip. Used for list. Defaults to 0."),
includeChangedFiles: z
.boolean()
.optional()
.default(false)
.describe(
"Whether to include the list of changed files. When changedFilesSummary returns a nextSkip greater than 0, pass nextSkip and nextTop back as skip and top for the next page. Used for get."
),
top: z.coerce.number().default(100).describe("The maximum number of pull requests to return (list), or of changed files to return (get with includeChangedFiles). Defaults to 100."),
skip: z.coerce.number().default(0).describe("The number of pull requests to skip (list), or of changed files to skip (get with includeChangedFiles). Defaults to 0."),
created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user. Used for list."),
created_by_user: z.string().optional().describe("Filter pull requests created by a specific user email. Used for list."),
i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer. Used for list."),
Expand Down Expand Up @@ -320,7 +326,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri
if (iterations?.length) {
const latestIteration = iterations[iterations.length - 1];
if (latestIteration.id != null) {
const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project, top, skip);
enhancedResponse = {
...enhancedResponse,
changedFilesSummary: {
Expand Down
84 changes: 83 additions & 1 deletion test/src/tools/repositories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { WebApi } from "azure-devops-node-api";
import { configureRepoTools, REPO_TOOLS } from "../../../src/tools/repositories";
import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, VersionControlRecursionType } from "azure-devops-node-api/interfaces/GitInterfaces.js";
import { getCurrentUserDetails, getUserIdFromEmail } from "../../../src/tools/auth";
import { z } from "zod";

// Mock the auth module
jest.mock("../../../src/tools/auth", () => ({
Expand Down Expand Up @@ -4478,7 +4479,7 @@ describe("repos tools", () => {
const result = await handler(params);

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

const resultData = parseSpotlightedPullRequest(result.content[0].text) as Record<string, unknown>;
expect(resultData.changedFilesSummary).toEqual({
Expand Down Expand Up @@ -4617,6 +4618,87 @@ describe("repos tools", () => {
secondComparingIteration: 1,
});
});
const changePage = (offset: number, count: number) =>
Array.from({ length: count }, (_, i) => ({ changeTrackingId: offset + i + 1, item: { path: `/src/file${offset + i + 1}.ts` }, changeType: 2 }));

const getPullRequestHandler = () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request);
if (!call) throw new Error("repo_pull_request tool not registered");
return call[3] as (params: unknown) => Promise<{ content: [{ text: string }] }>;
};

it("should default top to 100 and skip to 0 for the changed files call", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request);
if (!call) throw new Error("repo_pull_request tool not registered");
const [, , schema, handler] = call;

mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 123, title: "Test PR" });
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }, { id: 2 }]);
mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: changePage(0, 100), nextSkip: 100, nextTop: 50 });

// the handler is called directly elsewhere in this file, which skips the schema, so parse here
const parsed = z.object(schema).parse({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true });
expect(parsed.top).toBe(100);
expect(parsed.skip).toBe(0);

await handler(parsed);

expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("repo123", 123, 2, undefined, 100, 0);
});

it("should pass top and skip to the changed files call and return the next page markers", async () => {
const handler = getPullRequestHandler();
mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 123, title: "Test PR" });
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }, { id: 2 }]);
// what the endpoint returns for a 150-file iteration when asked for the first 100
mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: changePage(0, 100), nextSkip: 100, nextTop: 50 });

const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true, top: 100, skip: 0 });

expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledTimes(1);
expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("repo123", 123, 2, undefined, 100, 0);
const summary = (parseSpotlightedPullRequest(result.content[0].text) as Record<string, unknown>).changedFilesSummary as Record<string, unknown>;
expect(summary.fileCount).toBe(100);
expect((summary.changeEntries as { item: { path: string } }[])[0].item.path).toBe("/src/file1.ts");
expect(summary.nextSkip).toBe(100);
expect(summary.nextTop).toBe(50);
expect(summary).not.toHaveProperty("hasMore");
});

it("should pass skip through so the caller can fetch the next page of changed files", async () => {
const handler = getPullRequestHandler();
mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 123, title: "Test PR" });
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }, { id: 2 }]);
// Final page without continuation markers.
mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: changePage(100, 50) });

// the caller pages with the nextSkip and nextTop the first page returned
const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true, top: 50, skip: 100 });

expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("repo123", 123, 2, undefined, 50, 100);
const summary = (parseSpotlightedPullRequest(result.content[0].text) as Record<string, unknown>).changedFilesSummary as Record<string, unknown>;
expect(summary.fileCount).toBe(50);
expect((summary.changeEntries as { item: { path: string } }[])[49].item.path).toBe("/src/file150.ts");
expect(summary.nextSkip).toBeUndefined();
expect(summary.nextTop).toBeUndefined();
});

it("should surface an explicit nextSkip of 0 for the last page of changed files", async () => {
const handler = getPullRequestHandler();
mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 123, title: "Test PR" });
mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }]);
mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: changePage(0, 50), nextSkip: 0, nextTop: 0 });

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

expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("repo123", 123, 1, undefined, undefined, undefined);
const summary = (parseSpotlightedPullRequest(result.content[0].text) as Record<string, unknown>).changedFilesSummary as Record<string, unknown>;
expect(summary.fileCount).toBe(50);
expect(summary.nextSkip).toBe(0);
expect(summary.nextTop).toBe(0);
});
});

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