Skip to content

Commit b30bbd3

Browse files
committed
feat: add dual repository identifier support to search_commits
Add support for both numeric database IDs and string repository names in the search_commits tool, allowing users to directly use repository identifiers from list_repos output. **Implementation:** - Add resolveRepoId utility function to convert string repo names to numeric IDs via database lookup - Update SearchCommitsRequest interface to accept repoId: string | number - Wrap searchCommits in withOptionalAuthV2 for database access - Update MCP schema and API route schema with z.union([z.number(), z.string()]) **Testing:** - Add comprehensive unit tests for repository identifier resolution - Test both numeric ID and string name inputs - Test error handling for non-existent repositories - Verify database query parameters - All 29 tests passing **Documentation:** - Update README to document both identifier types - Update CHANGELOG with new functionality - Clear error messages guide users to use list_repos for valid identifiers This enhancement improves the user experience by eliminating the need to manually convert repository names to numeric IDs when using search_commits after list_repos. Signed-off-by: Wayne Sun <gsun@redhat.com>
1 parent 66fb4ad commit b30bbd3

File tree

6 files changed

+185
-15
lines changed

6 files changed

+185
-15
lines changed

packages/mcp/CHANGELOG.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Added comprehensive relative date support for all temporal parameters (e.g., "30 days ago", "last week", "yesterday")
12-
- Added `search_commits` tool to search commits by actual commit time with full temporal filtering
12+
- Added `search_commits` tool to search commits by actual commit time with full temporal filtering. Accepts both numeric database IDs (e.g., 123) and string repository names (e.g., "github.com/owner/repo") for the `repoId` parameter, allowing direct use of repository names from `list_repos` output
1313
- Added `since`/`until` parameters to `search_code` (filters by index time - when Sourcebot indexed the repo)
1414
- Added `gitRevision` parameter to `search_code`
1515
- Added `activeAfter`/`activeBefore` parameters to `list_repos` (filters by commit time - actual git commit dates)
@@ -21,12 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Added comprehensive unit tests for date parsing utilities (90+ test cases)
2222
- Added unit tests for git commit search functionality with mocking
2323
- Added integration tests for temporal parameter validation
24-
25-
### Changed
26-
- Enhanced `list_repos` tool with better pagination feedback messages
27-
28-
### Fixed
29-
- Fixed `list_repos` pagination bug where `take` limit was applied before activity filtering, returning fewer results than requested
24+
- Added unit tests for repository identifier resolution (both string and number types)
3025

3126
## [1.0.9] - 2025-11-17
3227

packages/mcp/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ Searches for commits in a specific repository based on actual commit time (NOT i
230230

231231
| Name | Required | Description |
232232
|:-----------|:---------|:-----------------------------------------------------------------------------------------------|
233-
| `repoId` | yes | Repository ID (obtain from `list_repos`). |
233+
| `repoId` | yes | Repository identifier: either numeric database ID (e.g., 123) or full repository name (e.g., "github.com/owner/repo") as returned by `list_repos`. |
234234
| `query` | no | Search query to filter commits by message (case-insensitive). |
235235
| `since` | no | Show commits after this date (by commit time). Supports ISO 8601 or relative formats. |
236236
| `until` | no | Show commits before this date (by commit time). Supports ISO 8601 or relative formats. |

packages/mcp/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,11 @@ server.tool(
194194
195195
If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.`,
196196
{
197-
repoId: z.number().describe(`The ID of the repository to search in. Obtain this by calling 'list_repos' first.`),
197+
repoId: z.union([z.number(), z.string()]).describe(`Repository identifier. Can be either:
198+
- Numeric database ID (e.g., 123)
199+
- Full repository name (e.g., "github.com/owner/repo") as returned by 'list_repos'
200+
201+
**YOU MUST** call 'list_repos' first to obtain the repository identifier.`),
198202
query: z.string().describe(`Search query to filter commits by message content (case-insensitive).`).optional(),
199203
since: z.string().describe(`Show commits more recent than this date. Filters by actual commit time. Supports ISO 8601 (e.g., '2024-01-01') or relative formats (e.g., '30 days ago', 'last week').`).optional(),
200204
until: z.string().describe(`Show commits older than this date. Filters by actual commit time. Supports ISO 8601 (e.g., '2024-12-31') or relative formats (e.g., 'yesterday').`).optional(),

packages/web/src/features/search/gitApi.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ vi.mock('@/actions', () => ({
2727
}
2828
},
2929
}));
30+
// Create a mock findFirst function that we can configure per-test
31+
const mockFindFirst = vi.fn();
32+
33+
vi.mock('@/withAuthV2', () => ({
34+
withOptionalAuthV2: async (fn: (args: any) => any) => {
35+
// Mock withOptionalAuthV2 to provide org and prisma context
36+
const mockOrg = { id: 1, name: 'test-org' };
37+
const mockPrisma = {
38+
repo: {
39+
findFirst: mockFindFirst,
40+
},
41+
};
42+
return await fn({ org: mockOrg, prisma: mockPrisma });
43+
},
44+
}));
45+
vi.mock('@/lib/utils', () => ({
46+
isServiceError: (obj: any) => {
47+
return obj && typeof obj === 'object' && 'errorCode' in obj;
48+
},
49+
}));
3050

3151
// Import mocked modules
3252
import { simpleGit } from 'simple-git';
@@ -451,4 +471,105 @@ describe('searchCommits', () => {
451471
expect(message).toContain('cloning process may not be finished yet');
452472
});
453473
});
474+
475+
describe('repository identifier resolution', () => {
476+
beforeEach(() => {
477+
// Reset mockFindFirst before each test in this suite
478+
mockFindFirst.mockReset();
479+
});
480+
481+
it('should accept numeric repository ID', async () => {
482+
mockGitLog.mockResolvedValue({ all: [] });
483+
484+
const result = await searchCommits({
485+
repoId: 123,
486+
});
487+
488+
expect(Array.isArray(result)).toBe(true);
489+
expect(mockExistsSync).toHaveBeenCalledWith('/mock/cache/dir/123');
490+
// mockFindFirst should not be called for numeric IDs
491+
expect(mockFindFirst).not.toHaveBeenCalled();
492+
});
493+
494+
it('should accept string repository name and resolve to numeric ID', async () => {
495+
mockFindFirst.mockResolvedValue({ id: 456 });
496+
mockGitLog.mockResolvedValue({ all: [] });
497+
498+
const result = await searchCommits({
499+
repoId: 'github.com/owner/repo',
500+
});
501+
502+
expect(Array.isArray(result)).toBe(true);
503+
expect(mockExistsSync).toHaveBeenCalledWith('/mock/cache/dir/456');
504+
expect(mockFindFirst).toHaveBeenCalledWith({
505+
where: {
506+
name: 'github.com/owner/repo',
507+
orgId: 1,
508+
},
509+
select: { id: true },
510+
});
511+
});
512+
513+
it('should return error when string repository name is not found', async () => {
514+
mockFindFirst.mockResolvedValue(null);
515+
516+
const result = await searchCommits({
517+
repoId: 'github.com/nonexistent/repo',
518+
});
519+
520+
expect(result).toMatchObject({
521+
errorCode: 'UNEXPECTED_ERROR',
522+
message: expect.stringContaining('Repository "github.com/nonexistent/repo" not found'),
523+
});
524+
expect(result).toMatchObject({
525+
message: expect.stringContaining('Use \'list_repos\' to get valid repository identifiers'),
526+
});
527+
});
528+
529+
it('should query database with correct parameters for string repo name', async () => {
530+
mockFindFirst.mockResolvedValue({ id: 789 });
531+
mockGitLog.mockResolvedValue({ all: [] });
532+
533+
await searchCommits({
534+
repoId: 'github.com/example/project',
535+
});
536+
537+
expect(mockFindFirst).toHaveBeenCalledWith({
538+
where: {
539+
name: 'github.com/example/project',
540+
orgId: 1,
541+
},
542+
select: { id: true },
543+
});
544+
});
545+
546+
it('should work with string repo name in full search scenario', async () => {
547+
const mockCommits = [
548+
{
549+
hash: 'xyz789',
550+
date: '2024-06-20T10:00:00Z',
551+
message: 'feat: new feature',
552+
refs: 'main',
553+
body: 'Added new functionality',
554+
author_name: 'Developer',
555+
author_email: 'dev@example.com',
556+
},
557+
];
558+
559+
mockFindFirst.mockResolvedValue({ id: 555 });
560+
vi.spyOn(dateUtils, 'validateDateRange').mockReturnValue(null);
561+
vi.spyOn(dateUtils, 'toGitDate').mockImplementation((date) => date);
562+
mockGitLog.mockResolvedValue({ all: mockCommits });
563+
564+
const result = await searchCommits({
565+
repoId: 'github.com/test/repository',
566+
query: 'feature',
567+
since: '7 days ago',
568+
author: 'Developer',
569+
});
570+
571+
expect(result).toEqual(mockCommits);
572+
expect(mockExistsSync).toHaveBeenCalledWith('/mock/cache/dir/555');
573+
});
574+
});
454575
});

packages/web/src/features/search/gitApi.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import path from 'path';
55
import { ServiceError, unexpectedError } from '@/lib/serviceError';
66
import { sew } from '@/actions';
77
import { toGitDate, validateDateRange } from './dateUtils';
8+
import { withOptionalAuthV2 } from '@/withAuthV2';
9+
import { isServiceError } from '@/lib/utils';
810

911
const createGitClientForPath = (repoPath: string) => {
1012
return simpleGit({
@@ -17,8 +19,46 @@ const createGitClientForPath = (repoPath: string) => {
1719
});
1820
}
1921

22+
/**
23+
* Resolves a repository identifier to a numeric ID.
24+
* Accepts both numeric IDs and string repository names.
25+
*
26+
* @param identifier - Either a numeric repo ID or a string repo name (e.g., "github.com/owner/repo")
27+
* @param orgId - Organization ID to scope the lookup
28+
* @param prisma - Prisma client instance
29+
* @returns Numeric repository ID or ServiceError if not found
30+
*/
31+
const resolveRepoId = async (
32+
identifier: string | number,
33+
orgId: number,
34+
prisma: any
35+
): Promise<number | ServiceError> => {
36+
// If already numeric, return as-is
37+
if (typeof identifier === 'number') {
38+
return identifier;
39+
}
40+
41+
// Convert string name to numeric ID
42+
const repo = await prisma.repo.findFirst({
43+
where: {
44+
name: identifier,
45+
orgId: orgId,
46+
},
47+
select: { id: true }
48+
});
49+
50+
if (!repo) {
51+
return unexpectedError(
52+
`Repository "${identifier}" not found. ` +
53+
`Use 'list_repos' to get valid repository identifiers.`
54+
);
55+
}
56+
57+
return repo.id;
58+
}
59+
2060
export interface SearchCommitsRequest {
21-
repoId: number;
61+
repoId: string | number;
2262
query?: string;
2363
since?: string;
2464
until?: string;
@@ -48,18 +88,28 @@ export interface Commit {
4888
* process might not be finished when this query is executed. If the repository
4989
* is not found on the server disk, an error will be returned.
5090
*
91+
* **Repository ID**: Accepts either a numeric database ID or a string repository name
92+
* (e.g., "github.com/owner/repo") as returned by list_repos.
93+
*
5194
* @param request - Search parameters including timeframe filters
5295
* @returns Array of commits or ServiceError
5396
*/
5497
export const searchCommits = async ({
55-
repoId,
98+
repoId: repoIdInput,
5699
query,
57100
since,
58101
until,
59102
author,
60103
maxCount = 50,
61-
}: SearchCommitsRequest): Promise<Commit[] | ServiceError> => sew(async () => {
62-
const repoPath = path.join(REPOS_CACHE_DIR, repoId.toString());
104+
}: SearchCommitsRequest): Promise<Commit[] | ServiceError> => sew(() =>
105+
withOptionalAuthV2(async ({ org, prisma }) => {
106+
// Resolve repository identifier to numeric ID
107+
const repoId = await resolveRepoId(repoIdInput, org.id, prisma);
108+
if (isServiceError(repoId)) {
109+
return repoId;
110+
}
111+
112+
const repoPath = path.join(REPOS_CACHE_DIR, repoId.toString());
63113

64114
// Check if repository exists on Sourcebot server disk
65115
if (!existsSync(repoPath)) {
@@ -143,4 +193,4 @@ export const searchCommits = async ({
143193
);
144194
}
145195
}
146-
});
196+
}));

packages/web/src/features/search/schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const fileSourceResponseSchema = z.object({
169169
});
170170

171171
export const searchCommitsRequestSchema = z.object({
172-
repoId: z.number(),
172+
repoId: z.union([z.number(), z.string()]),
173173
query: z.string().optional(),
174174
since: z.string().optional(),
175175
until: z.string().optional(),

0 commit comments

Comments
 (0)