Skip to content

Commit 6328ad5

Browse files
authored
[Kibana Dev MCP] Add search_by_codeowner tool (#241930)
1 parent 97051ae commit 6328ad5

File tree

3 files changed

+528
-0
lines changed

3 files changed

+528
-0
lines changed

src/platform/packages/shared/kbn-mcp-dev-server/src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { generateKibanaPackageTool } from '../tools/generate_package';
1717
import { listKibanaTeamsTool } from '../tools/list_teams';
1818
import { runUnitTestsTool } from '../tools/run_unit_tests';
1919
import { runCiChecksTool } from '../tools/run_ci_checks';
20+
import { searchByCodeownerTool } from '../tools/search_by_codeowner';
2021
import { findDependencyReferencesTool } from '../tools/find_dependency_references';
2122

2223
run(async () => {
@@ -27,6 +28,7 @@ run(async () => {
2728
addTool(server, listKibanaTeamsTool);
2829
addTool(server, runUnitTestsTool);
2930
addTool(server, runCiChecksTool);
31+
addTool(server, searchByCodeownerTool);
3032
addTool(server, findDependencyReferencesTool);
3133

3234
const transport = new StdioServerTransport();
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import fs from 'fs';
11+
12+
// Use var for proper hoisting in Jest mocks (const/let have temporal dead zone issues)
13+
// eslint-disable-next-line no-var
14+
var mockExecFileAsync: jest.Mock;
15+
16+
jest.mock('fs');
17+
jest.mock('@kbn/repo-info', () => ({ REPO_ROOT: '/repo/root' }));
18+
jest.mock('child_process', () => ({
19+
execFile: jest.fn(),
20+
}));
21+
jest.mock('util', () => {
22+
const actual = jest.requireActual('util');
23+
mockExecFileAsync = jest.fn();
24+
return {
25+
...actual,
26+
promisify: jest.fn(() => mockExecFileAsync),
27+
};
28+
});
29+
30+
import { searchByCodeownerTool } from './search_by_codeowner';
31+
32+
const mockedFs = fs as jest.Mocked<typeof fs>;
33+
34+
describe('searchByCodeownerTool', () => {
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
const setupMocks = (options: {
40+
matchingFiles: string[];
41+
codeowners?: string;
42+
grepError?: { code: number; stdout?: string; stderr?: string };
43+
}) => {
44+
const { matchingFiles, codeowners, grepError } = options;
45+
46+
// Mock CODEOWNERS file
47+
(mockedFs.readFileSync as jest.Mock).mockImplementation((filePath: string) => {
48+
if (filePath.includes('CODEOWNERS')) {
49+
return (
50+
codeowners ||
51+
`
52+
/src @elastic/kibana-core
53+
/x-pack @elastic/kibana-platform
54+
`.trim()
55+
);
56+
}
57+
throw new Error(`File not found: ${filePath}`);
58+
});
59+
60+
// Mock fs.statSync to check if directories exist
61+
(mockedFs.statSync as jest.Mock).mockImplementation((dirPath: string) => {
62+
const relativePath = dirPath.replace('/repo/root/', '');
63+
// Assume src and x-pack directories exist
64+
if (
65+
relativePath === 'src' ||
66+
relativePath === 'x-pack' ||
67+
relativePath.startsWith('src/') ||
68+
relativePath.startsWith('x-pack/')
69+
) {
70+
return { isDirectory: () => true, isFile: () => false };
71+
}
72+
throw new Error('Path does not exist');
73+
});
74+
75+
// Mock grep execFile
76+
if (grepError) {
77+
mockExecFileAsync.mockRejectedValue(grepError);
78+
} else {
79+
mockExecFileAsync.mockResolvedValue({
80+
stdout: matchingFiles.map((f) => `/repo/root/${f}`).join('\n'),
81+
stderr: '',
82+
});
83+
}
84+
};
85+
86+
describe('handler', () => {
87+
it('finds files containing search term owned by specified team', async () => {
88+
setupMocks({
89+
matchingFiles: ['src/file1.ts', 'src/file2.tsx'],
90+
});
91+
92+
const result = await searchByCodeownerTool.handler({
93+
searchTerm: 'TODO',
94+
team: '@elastic/kibana-core',
95+
});
96+
97+
const parsedResult = JSON.parse(result.content[0].text as string);
98+
99+
expect(parsedResult.searchTerm).toBe('TODO');
100+
expect(parsedResult.team).toBe('@elastic/kibana-core');
101+
expect(parsedResult.totalMatchingFiles).toBe(2);
102+
expect(parsedResult.matchingFiles).toContain('src/file1.ts');
103+
expect(parsedResult.matchingFiles).toContain('src/file2.tsx');
104+
expect(parsedResult.totalScannedFiles).toBe(1); // 1 directory searched
105+
});
106+
107+
it('normalizes team name by adding @ prefix if missing', async () => {
108+
setupMocks({
109+
matchingFiles: ['src/file1.ts'],
110+
});
111+
112+
const result = await searchByCodeownerTool.handler({
113+
searchTerm: 'TODO',
114+
team: 'elastic/kibana-core',
115+
});
116+
117+
const parsedResult = JSON.parse(result.content[0].text as string);
118+
119+
expect(parsedResult.team).toBe('@elastic/kibana-core');
120+
expect(parsedResult.totalMatchingFiles).toBe(1);
121+
});
122+
123+
it('returns zero results when no files match', async () => {
124+
setupMocks({
125+
matchingFiles: [],
126+
grepError: {
127+
code: 1,
128+
stdout: '',
129+
},
130+
});
131+
132+
const result = await searchByCodeownerTool.handler({
133+
searchTerm: 'NONEXISTENT',
134+
team: '@elastic/kibana-core',
135+
});
136+
137+
const parsedResult = JSON.parse(result.content[0].text as string);
138+
139+
expect(parsedResult.totalMatchingFiles).toBe(0);
140+
expect(parsedResult.matchingFiles).toHaveLength(0);
141+
});
142+
143+
it('returns zero results when team has no owned paths', async () => {
144+
setupMocks({
145+
matchingFiles: [],
146+
codeowners: `
147+
/src @elastic/kibana-core
148+
/x-pack @elastic/kibana-platform
149+
`.trim(),
150+
});
151+
152+
const result = await searchByCodeownerTool.handler({
153+
searchTerm: 'TODO',
154+
team: '@elastic/kibana-security',
155+
});
156+
157+
const parsedResult = JSON.parse(result.content[0].text as string);
158+
159+
expect(parsedResult.totalMatchingFiles).toBe(0);
160+
expect(parsedResult.totalScannedFiles).toBe(0); // No directories for this team
161+
});
162+
163+
it('only searches in directories that exist', async () => {
164+
(mockedFs.readFileSync as jest.Mock).mockImplementation((filePath: string) => {
165+
if (filePath.includes('CODEOWNERS')) {
166+
return `
167+
/src @elastic/kibana-core
168+
/nonexistent @elastic/kibana-core
169+
`.trim();
170+
}
171+
throw new Error(`File not found: ${filePath}`);
172+
});
173+
174+
(mockedFs.statSync as jest.Mock).mockImplementation((dirPath: string) => {
175+
if (dirPath.includes('nonexistent')) {
176+
throw new Error('Path does not exist');
177+
}
178+
return { isDirectory: () => true, isFile: () => false };
179+
});
180+
181+
mockExecFileAsync.mockResolvedValue({
182+
stdout: '/repo/root/src/file1.ts',
183+
stderr: '',
184+
});
185+
186+
const result = await searchByCodeownerTool.handler({
187+
searchTerm: 'TODO',
188+
team: '@elastic/kibana-core',
189+
});
190+
191+
const parsedResult = JSON.parse(result.content[0].text as string);
192+
193+
// Should only search in /src (which exists), not /nonexistent
194+
expect(parsedResult.totalScannedFiles).toBe(1);
195+
expect(parsedResult.totalMatchingFiles).toBe(1);
196+
});
197+
198+
it('handles grep errors gracefully', async () => {
199+
setupMocks({
200+
matchingFiles: [],
201+
grepError: {
202+
code: 2,
203+
stderr: 'grep error',
204+
},
205+
});
206+
207+
const result = await searchByCodeownerTool.handler({
208+
searchTerm: 'TODO',
209+
team: '@elastic/kibana-core',
210+
});
211+
212+
const parsedResult = JSON.parse(result.content[0].text as string);
213+
214+
expect(parsedResult.totalMatchingFiles).toBe(0);
215+
});
216+
217+
it('handles grep exit code 1 with stdout', async () => {
218+
setupMocks({
219+
matchingFiles: [],
220+
grepError: {
221+
code: 1,
222+
stdout: '/repo/root/src/file1.ts\n/repo/root/src/file2.ts',
223+
},
224+
});
225+
226+
const result = await searchByCodeownerTool.handler({
227+
searchTerm: 'TODO',
228+
team: '@elastic/kibana-core',
229+
});
230+
231+
const parsedResult = JSON.parse(result.content[0].text as string);
232+
233+
expect(parsedResult.totalMatchingFiles).toBe(2);
234+
expect(parsedResult.matchingFiles).toContain('src/file1.ts');
235+
expect(parsedResult.matchingFiles).toContain('src/file2.ts');
236+
});
237+
238+
it('returns analysis time in milliseconds', async () => {
239+
setupMocks({
240+
matchingFiles: ['src/file1.ts'],
241+
});
242+
243+
const result = await searchByCodeownerTool.handler({
244+
searchTerm: 'TODO',
245+
team: '@elastic/kibana-core',
246+
});
247+
248+
const parsedResult = JSON.parse(result.content[0].text as string);
249+
250+
expect(parsedResult.analysisTimeMs).toBeGreaterThanOrEqual(0);
251+
expect(typeof parsedResult.analysisTimeMs).toBe('number');
252+
});
253+
254+
it('sorts matching files alphabetically', async () => {
255+
setupMocks({
256+
matchingFiles: ['src/zzz.ts', 'src/aaa.ts', 'src/mmm.ts'],
257+
});
258+
259+
const result = await searchByCodeownerTool.handler({
260+
searchTerm: 'TODO',
261+
team: '@elastic/kibana-core',
262+
});
263+
264+
const parsedResult = JSON.parse(result.content[0].text as string);
265+
266+
expect(parsedResult.matchingFiles).toEqual(['src/aaa.ts', 'src/mmm.ts', 'src/zzz.ts']);
267+
});
268+
269+
it('converts absolute paths to relative paths', async () => {
270+
setupMocks({
271+
matchingFiles: ['src/file1.ts'],
272+
});
273+
274+
const result = await searchByCodeownerTool.handler({
275+
searchTerm: 'TODO',
276+
team: '@elastic/kibana-core',
277+
});
278+
279+
const parsedResult = JSON.parse(result.content[0].text as string);
280+
281+
// Should return relative paths, not absolute
282+
expect(parsedResult.matchingFiles[0]).not.toContain('/repo/root');
283+
expect(parsedResult.matchingFiles[0]).toBe('src/file1.ts');
284+
});
285+
});
286+
287+
describe('tool definition', () => {
288+
it('has correct name', () => {
289+
expect(searchByCodeownerTool.name).toBe('search_by_codeowner');
290+
});
291+
292+
it('has a description', () => {
293+
expect(searchByCodeownerTool.description).toBeTruthy();
294+
expect(typeof searchByCodeownerTool.description).toBe('string');
295+
});
296+
297+
it('has an input schema', () => {
298+
expect(searchByCodeownerTool.inputSchema).toBeDefined();
299+
});
300+
301+
it('has a handler function', () => {
302+
expect(searchByCodeownerTool.handler).toBeDefined();
303+
expect(typeof searchByCodeownerTool.handler).toBe('function');
304+
});
305+
});
306+
});

0 commit comments

Comments
 (0)