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
131 changes: 121 additions & 10 deletions src/tools/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,10 +930,11 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri

server.tool(
REPO_TOOLS.search_commits,
"Searches for commits in a repository",
"Search for commits in a repository with comprehensive filtering capabilities. Supports searching by description/comment text, time range, author, committer, specific commit IDs, and more. This is the unified tool for all commit search operations.",
{
project: z.string().describe("Project name or ID"),
repository: z.string().describe("Repository name or ID"),
// Existing parameters
fromCommit: z.string().optional().describe("Starting commit ID"),
toCommit: z.string().optional().describe("Ending commit ID"),
version: z.string().optional().describe("The name of the branch, tag or commit to filter commits by"),
Expand All @@ -946,19 +947,110 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri
top: z.number().optional().default(10).describe("Maximum number of commits to return"),
includeLinks: z.boolean().optional().default(false).describe("Include commit links"),
includeWorkItems: z.boolean().optional().default(false).describe("Include associated work items"),
// Enhanced search parameters
searchText: z.string().optional().describe("Search text to filter commits by description/comment. Supports partial matching."),
author: z.string().optional().describe("Filter commits by author email or display name"),
authorEmail: z.string().optional().describe("Filter commits by exact author email address"),
committer: z.string().optional().describe("Filter commits by committer email or display name"),
committerEmail: z.string().optional().describe("Filter commits by exact committer email address"),
fromDate: z.string().optional().describe("Filter commits from this date (ISO 8601 format, e.g., '2024-01-01T00:00:00Z')"),
toDate: z.string().optional().describe("Filter commits to this date (ISO 8601 format, e.g., '2024-12-31T23:59:59Z')"),
commitIds: z.array(z.string()).optional().describe("Array of specific commit IDs to retrieve. When provided, other filters are ignored except top/skip."),
historySimplificationMode: z.enum(["FirstParent", "SimplifyMerges", "FullHistory", "FullHistorySimplifyMerges"]).optional().describe("How to simplify the commit history"),
},
async ({ project, repository, fromCommit, toCommit, version, versionType, skip, top, includeLinks, includeWorkItems }) => {
async ({
project,
repository,
fromCommit,
toCommit,
version,
versionType,
skip,
top,
includeLinks,
includeWorkItems,
searchText,
author,
authorEmail,
committer,
committerEmail,
fromDate,
toDate,
commitIds,
historySimplificationMode,
}) => {
try {
const connection = await connectionProvider();
const gitApi = await connection.getGitApi();

// If specific commit IDs are provided, use getCommits with commit ID filtering
if (commitIds && commitIds.length > 0) {
const commits = [];
const batchSize = Math.min(top || 10, commitIds.length);
const startIndex = skip || 0;
const endIndex = Math.min(startIndex + batchSize, commitIds.length);

// Process commits in the requested range
const requestedCommitIds = commitIds.slice(startIndex, endIndex);

// Use getCommits for each commit ID to maintain consistency
for (const commitId of requestedCommitIds) {
try {
const searchCriteria: GitQueryCommitsCriteria = {
includeLinks: includeLinks,
includeWorkItems: includeWorkItems,
fromCommitId: commitId,
toCommitId: commitId,
};

const commitResults = await gitApi.getCommits(repository, searchCriteria, project, 0, 1);

if (commitResults && commitResults.length > 0) {
commits.push(commitResults[0]);
}
} catch (error) {
// Log error but continue with other commits
console.warn(`Failed to retrieve commit ${commitId}: ${error instanceof Error ? error.message : String(error)}`);
// Add error information to result instead of failing completely
commits.push({
commitId: commitId,
error: `Failed to retrieve: ${error instanceof Error ? error.message : String(error)}`,
});
}
}

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

const searchCriteria: GitQueryCommitsCriteria = {
fromCommitId: fromCommit,
toCommitId: toCommit,
includeLinks: includeLinks,
includeWorkItems: includeWorkItems,
};

// Add author filter
if (author) {
searchCriteria.author = author;
}

// Add date range filters (ADO API expects ISO string format)
if (fromDate) {
searchCriteria.fromDate = fromDate;
}
if (toDate) {
searchCriteria.toDate = toDate;
}

// Add history simplification if specified
if (historySimplificationMode) {
// Note: This parameter might not be directly supported by all ADO API versions
// but we'll include it in the criteria for forward compatibility
(searchCriteria as any).historySimplificationMode = historySimplificationMode;
}

if (version) {
const itemVersion: GitVersionDescriptor = {
version: version,
Expand All @@ -967,16 +1059,35 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<stri
searchCriteria.itemVersion = itemVersion;
}

const commits = await gitApi.getCommits(
repository,
searchCriteria,
project,
skip, // skip
top
);
const commits = await gitApi.getCommits(repository, searchCriteria, project, skip, top);

// Additional client-side filtering for enhanced search capabilities
let filteredCommits = commits;

// Filter by search text in commit message if not handled by API
if (searchText && filteredCommits) {
filteredCommits = filteredCommits.filter((commit) => commit.comment?.toLowerCase().includes(searchText.toLowerCase()));
}

// Filter by author email if specified
if (authorEmail && filteredCommits) {
filteredCommits = filteredCommits.filter((commit) => commit.author?.email?.toLowerCase() === authorEmail.toLowerCase());
}

// Filter by committer if specified
if (committer && filteredCommits) {
filteredCommits = filteredCommits.filter(
(commit) => commit.committer?.name?.toLowerCase().includes(committer.toLowerCase()) || commit.committer?.email?.toLowerCase().includes(committer.toLowerCase())
);
}

// Filter by committer email if specified
if (committerEmail && filteredCommits) {
filteredCommits = filteredCommits.filter((commit) => commit.committer?.email?.toLowerCase() === committerEmail.toLowerCase());
}

return {
content: [{ type: "text", text: JSON.stringify(commits, null, 2) }],
content: [{ type: "text", text: JSON.stringify(filteredCommits, null, 2) }],
};
} catch (error) {
return {
Expand Down
96 changes: 96 additions & 0 deletions test/src/tools/repositories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4244,4 +4244,100 @@ describe("repos tools", () => {
expect(result.content[0].text).toBe(JSON.stringify(mockThread, null, 2));
});
});

describe("enhanced commit search functions", () => {
describe("repo_search_commits enhanced functionality", () => {
it("should search commits with enhanced filters", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockCommits = [
{
commitId: "abc123",
comment: "Fix bug in authentication",
author: { name: "John Doe", email: "john@example.com" },
committer: { name: "John Doe", email: "john@example.com" },
push: { date: "2023-01-01T00:00:00Z" },
},
];
mockGitApi.getCommits.mockResolvedValue(mockCommits);

const params = {
project: "test-project",
repository: "test-repo",
searchText: "authentication",
author: "John Doe",
fromDate: "2023-01-01T00:00:00Z",
toDate: "2023-12-31T23:59:59Z",
top: 10,
};

const result = await handler(params);

expect(mockGitApi.getCommits).toHaveBeenCalledWith(
"test-repo",
expect.objectContaining({
author: "John Doe",
fromDate: "2023-01-01T00:00:00Z",
toDate: "2023-12-31T23:59:59Z",
}),
"test-project",
undefined,
10
);

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

it("should retrieve specific commits by IDs", async () => {
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);

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

const mockCommit1 = { commitId: "abc123", comment: "First commit" };
const mockCommit2 = { commitId: "def456", comment: "Second commit" };

mockGitApi.getCommits.mockResolvedValueOnce([mockCommit1]).mockResolvedValueOnce([mockCommit2]);

const params = {
project: "test-project",
repository: "test-repo",
commitIds: ["abc123", "def456"],
top: 10,
};

const result = await handler(params);

expect(mockGitApi.getCommits).toHaveBeenCalledTimes(2);
expect(mockGitApi.getCommits).toHaveBeenCalledWith(
"test-repo",
expect.objectContaining({
fromCommitId: "abc123",
toCommitId: "abc123",
}),
"test-project",
0,
1
);
expect(mockGitApi.getCommits).toHaveBeenCalledWith(
"test-repo",
expect.objectContaining({
fromCommitId: "def456",
toCommitId: "def456",
}),
"test-project",
0,
1
);

const expectedCommits = [mockCommit1, mockCommit2];
expect(result.content[0].text).toBe(JSON.stringify(expectedCommits, null, 2));
});
});
});
});
Loading