diff --git a/CHANGELOG.md b/CHANGELOG.md index cef64e050..6bd9dbe1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added ask sidebar to homepage. [#721](https://github.com/sourcebot-dev/sourcebot/pull/721) - Added endpoint for searching commit history for a git repository. [#625](https://github.com/sourcebot-dev/sourcebot/pull/625) - Added `pushedAt` field to the Repo table to track when a repository last was committed to across all branches. [#790](https://github.com/sourcebot-dev/sourcebot/pull/790) +- Added offset pagination to the `/api/repos` endpoint. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Added offset pagination to the `/api/commits` endpoint. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) ### Changed - Added commit graph generation to improve performance for commit traversal operations. [#791](https://github.com/sourcebot-dev/sourcebot/pull/791) +- Made the code search `lang:` filter case insensitive. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Changed the `/api/source` endpoint from a POST request to a GET request. Repo, path, and ref are specified as query params. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Changed the `/api/commits` endpoint from a POST request to a GET request. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Renamed `webUrl` to `externalWebUrl` for various apis. Moving forward, `webUrl` will be used for URLs that point to Sourcebot, and `externalWebUrl` will be used for URLs that point to external code hosts. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Renamed various fields on the `/api/source` endpoint response body. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) ### Fixed - Fixed issue where a file would fail to load when opening it from the /search view and it matched multiple branches. [#797](https://github.com/sourcebot-dev/sourcebot/pull/797) diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 5eb028c03..74d91e6ab 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `search_commits` tool to search a repos commit history. [#625](https://github.com/sourcebot-dev/sourcebot/pull/625) - Added `gitRevision` parameter to the `search_code` tool to allow for searching on different branches. [#625](https://github.com/sourcebot-dev/sourcebot/pull/625) +- Added server side pagination support for `list_commits` and `list_repos`. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Added `filterByFilepaths` and `useRegex` params to the `search_code` tool. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) + +### Changed +- Renamed `search_commits` tool to `list_commits`. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Renamed `gitRevision` param to `ref` on `search_code` tool. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) +- Generally improved tool and tool param descriptions for all tools. [#795](https://github.com/sourcebot-dev/sourcebot/pull/795) ## [1.0.12] - 2026-01-13 diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 15636c23f..24b67fe6a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -19,6 +19,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@t3-oss/env-core": "^0.13.4", + "dedent": "^1.7.1", "escape-string-regexp": "^5.0.0", "express": "^5.1.0", "zod": "^3.24.3" diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index bf2a2c192..036f251b6 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,72 +1,105 @@ import { env } from './env.js'; -import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema, searchCommitsResponseSchema } from './schemas.js'; -import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse, ServiceError, SearchCommitsRequest, SearchCommitsResponse } from './types.js'; -import { isServiceError } from './utils.js'; +import { listReposResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema } from './schemas.js'; +import { FileSourceRequest, ListReposQueryParams, SearchRequest, ListCommitsQueryParamsSchema } from './types.js'; +import { isServiceError, ServiceErrorException } from './utils.js'; +import { z } from 'zod'; -export const search = async (request: SearchRequest): Promise => { - const result = await fetch(`${env.SOURCEBOT_HOST}/api/search`, { +const parseResponse = async ( + response: Response, + schema: T +): Promise> => { + const text = await response.text(); + + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new Error(`Invalid JSON response: ${text}`); + } + + // Check if the response is already a service error from the API + if (isServiceError(json)) { + throw new ServiceErrorException(json); + } + + const parsed = schema.safeParse(json); + if (!parsed.success) { + throw new Error(`Failed to parse response: ${parsed.error.message}`); + } + + return parsed.data; +}; + +export const search = async (request: SearchRequest) => { + const response = await fetch(`${env.SOURCEBOT_HOST}/api/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) }, body: JSON.stringify(request) - }).then(response => response.json()); + }); - if (isServiceError(result)) { - return result; - } - - return searchResponseSchema.parse(result); + return parseResponse(response, searchResponseSchema); } -export const listRepos = async (): Promise => { - const result = await fetch(`${env.SOURCEBOT_HOST}/api/repos`, { +export const listRepos = async (queryParams: ListReposQueryParams = {}) => { + const url = new URL(`${env.SOURCEBOT_HOST}/api/repos`); + + for (const [key, value] of Object.entries(queryParams)) { + if (value) { + url.searchParams.set(key, value.toString()); + } + } + + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) }, - }).then(response => response.json()); - - if (isServiceError(result)) { - return result; - } + }); - return listRepositoriesResponseSchema.parse(result); + const repos = await parseResponse(response, listReposResponseSchema); + const totalCount = parseInt(response.headers.get('X-Total-Count') ?? '0', 10); + return { repos, totalCount }; } -export const getFileSource = async (request: FileSourceRequest): Promise => { - const result = await fetch(`${env.SOURCEBOT_HOST}/api/source`, { - method: 'POST', +export const getFileSource = async (request: FileSourceRequest) => { + const url = new URL(`${env.SOURCEBOT_HOST}/api/source`); + for (const [key, value] of Object.entries(request)) { + if (value) { + url.searchParams.set(key, value.toString()); + } + } + + const response = await fetch(url, { + method: 'GET', headers: { - 'Content-Type': 'application/json', ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) }, - body: JSON.stringify(request) - }).then(response => response.json()); - - if (isServiceError(result)) { - return result; - } + }); - return fileSourceResponseSchema.parse(result); + return parseResponse(response, fileSourceResponseSchema); } -export const searchCommits = async (request: SearchCommitsRequest): Promise => { - const result = await fetch(`${env.SOURCEBOT_HOST}/api/commits`, { - method: 'POST', +export const listCommits = async (queryParams: ListCommitsQueryParamsSchema) => { + const url = new URL(`${env.SOURCEBOT_HOST}/api/commits`); + for (const [key, value] of Object.entries(queryParams)) { + if (value) { + url.searchParams.set(key, value.toString()); + } + } + + const response = await fetch(url, { + method: 'GET', headers: { - 'Content-Type': 'application/json', 'X-Org-Domain': '~', ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) }, - body: JSON.stringify(request) - }).then(response => response.json()); - - if (isServiceError(result)) { - return result; - } + }); - return searchCommitsResponseSchema.parse(result); + const commits = await parseResponse(response, listCommitsResponseSchema); + const totalCount = parseInt(response.headers.get('X-Total-Count') ?? '0', 10); + return { commits, totalCount }; } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d30c79cef..e306d8670 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -3,13 +3,15 @@ // Entry point for the MCP server import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import _dedent from "dedent"; import escapeStringRegexp from 'escape-string-regexp'; import { z } from 'zod'; -import { getFileSource, listRepos, search, searchCommits } from './client.js'; +import { getFileSource, listCommits, listRepos, search } from './client.js'; import { env, numberSchema } from './env.js'; -import { listReposRequestSchema } from './schemas.js'; -import { TextContent } from './types.js'; -import { isServiceError } from './utils.js'; +import { fileSourceRequestSchema, listCommitsQueryParamsSchema, listReposQueryParamsSchema } from './schemas.js'; +import { FileSourceRequest, ListCommitsQueryParamsSchema, ListReposQueryParams, TextContent } from './types.js'; + +const dedent = _dedent.withOptions({ alignValues: true }); // Create MCP server const server = new McpServer({ @@ -20,26 +22,34 @@ const server = new McpServer({ server.tool( "search_code", - `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search. - Results are returned as an array of matching files, with the file's URL, repository, and language. - If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable. - If the \`includeCodeSnippets\` property is true, code snippets containing the matches will be included in the response. Only set this to true if the request requires code snippets (e.g., show me examples where library X is used). - When referencing a file in your response, **ALWAYS** include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out. - **ONLY USE** the \`filterByRepoIds\` property if the request requires searching a specific repo(s). Otherwise, leave it empty.`, + dedent` + Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the \`list_repos\` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches. When referencing code outputted by this tool, always include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out. + `, { query: z .string() - .describe(`The regex pattern to search for. RULES: - 1. When a regex special character needs to be escaped, ALWAYS use a single backslash (\) (e.g., 'console\.log') - 2. **ALWAYS** escape spaces with a single backslash (\) (e.g., 'console\ log') - `), - filterByRepoIds: z + .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) + // Escape backslashes first, then quotes, and wrap in double quotes + // so the query is treated as a literal phrase (like grep). + .transform((val) => { + const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; + }), + useRegex: z + .boolean() + .describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`) + .optional(), + filterByRepos: z .array(z.string()) - .describe(`Scope the search to the provided repositories to the Sourcebot compatible repository IDs. **DO NOT** use this property if you want to search all repositories. **YOU MUST** call 'list_repos' first to obtain the exact repository ID.`) + .describe(`Scope the search to the provided repositories.`) .optional(), filterByLanguages: z .array(z.string()) - .describe(`Scope the search to the provided languages. The language MUST be formatted as a GitHub linguist language. Examples: Python, JavaScript, TypeScript, Java, C#, C++, PHP, Go, Rust, Ruby, Swift, Kotlin, Shell, C, Dart, HTML, CSS, PowerShell, SQL, R`) + .describe(`Scope the search to the provided languages.`) + .optional(), + filterByFilepaths: z + .array(z.string()) + .describe(`Scope the search to the provided filepaths.`) .optional(), caseSensitive: z .boolean() @@ -47,11 +57,11 @@ server.tool( .optional(), includeCodeSnippets: z .boolean() - .describe(`Whether to include the code snippets in the response (default: false). If false, only the file's URL, repository, and language will be returned. Set to false to get a more concise response.`) + .describe(`Whether to include the code snippets in the response. If false, only the file's URL, repository, and language will be returned. (default: false)`) .optional(), - gitRevision: z + ref: z .string() - .describe(`The git revision to search in (e.g., 'main', 'HEAD', 'v1.0.0', 'a1b2c3d'). If not provided, defaults to the default branch (usually 'main' or 'master').`) + .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) .optional(), maxTokens: numberSchema .describe(`The maximum number of tokens to return (default: ${env.DEFAULT_MINIMUM_TOKENS}). Higher values provide more context but consume more tokens. Values less than ${env.DEFAULT_MINIMUM_TOKENS} will be ignored.`) @@ -60,43 +70,40 @@ server.tool( }, async ({ query, - filterByRepoIds: repoIds = [], + filterByRepos: repos = [], filterByLanguages: languages = [], + filterByFilepaths: filepaths = [], maxTokens = env.DEFAULT_MINIMUM_TOKENS, includeCodeSnippets = false, caseSensitive = false, - gitRevision, + ref, + useRegex = false, }) => { - if (repoIds.length > 0) { - query += ` ( repo:${repoIds.map(id => escapeStringRegexp(id)).join(' or repo:')} )`; + if (repos.length > 0) { + query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; } if (languages.length > 0) { - query += ` ( lang:${languages.join(' or lang:')} )`; + query += ` (lang:${languages.join(' or lang:')})`; + } + + if (filepaths.length > 0) { + query += ` (file:${filepaths.map(filepath => escapeStringRegexp(filepath)).join(' or file:')})`; } - if (gitRevision) { - query += ` ( rev:${gitRevision} )`; + if (ref) { + query += ` ( rev:${ref} )`; } const response = await search({ query, matches: env.DEFAULT_MATCHES, contextLines: env.DEFAULT_CONTEXT_LINES, - isRegexEnabled: true, + isRegexEnabled: useRegex, isCaseSensitivityEnabled: caseSensitive, source: 'mcp', }); - if (isServiceError(response)) { - return { - content: [{ - type: "text", - text: `Error searching code: ${response.message}`, - }], - }; - } - if (response.files.length === 0) { return { content: [{ @@ -115,8 +122,12 @@ server.tool( (acc, chunk) => acc + chunk.matchRanges.length, 0, ); - const fileIdentifier = file.webUrl ?? file.fileName.text; - let text = `file: ${fileIdentifier}\nnum_matches: ${numMatches}\nrepository: ${file.repository}\nlanguage: ${file.language}`; + let text = dedent` + file: ${file.webUrl} + num_matches: ${numMatches} + repo: ${file.repository} + language: ${file.language} + `; if (includeCodeSnippets) { const snippets = file.chunks.map(chunk => { @@ -172,133 +183,58 @@ server.tool( ); server.tool( - "search_commits", - `Searches for commits in a specific repository based on actual commit time. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.`, - { - repoId: z.string().describe(`The repository to search commits in. This is the Sourcebot compatible repository ID as returned by 'list_repos'.`), - query: z.string().describe(`Search query to filter commits by message content (case-insensitive).`).optional(), - 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(), - 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(), - author: z.string().describe(`Filter commits by author name or email (supports partial matches and patterns).`).optional(), - maxCount: z.number().int().positive().default(50).describe(`Maximum number of commits to return (default: 50).`), - }, - async ({ repoId, query, since, until, author, maxCount }) => { - const result = await searchCommits({ - repository: repoId, - query, - since, - until, - author, - maxCount, - }); - - if (isServiceError(result)) { - return { - content: [{ type: "text", text: `Error: ${result.message}` }], - isError: true, - }; - } + "list_commits", + dedent`Get a list of commits for a given repository.`, + listCommitsQueryParamsSchema.shape, + async (request: ListCommitsQueryParamsSchema) => { + const result = await listCommits(request); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ + type: "text", text: JSON.stringify(result) + }], }; } ); server.tool( "list_repos", - `Lists repositories in the organization with optional filtering and pagination. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.`, - listReposRequestSchema.shape, - async ({ query, pageNumber = 1, limit = 50 }: { - query?: string; - pageNumber?: number; - limit?: number; - }) => { - const response = await listRepos(); - if (isServiceError(response)) { - return { - content: [{ - type: "text", - text: `Error listing repositories: ${response.message}`, - }], - }; - } - - // Apply query filter if provided - let filtered = response; - if (query) { - const lowerQuery = query.toLowerCase(); - filtered = response.filter(repo => - repo.repoName.toLowerCase().includes(lowerQuery) || - repo.repoDisplayName?.toLowerCase().includes(lowerQuery) - ); - } - - // Sort alphabetically for consistent pagination - filtered.sort((a, b) => a.repoName.localeCompare(b.repoName)); - - // Apply pagination - const startIndex = (pageNumber - 1) * limit; - const endIndex = startIndex + limit; - const paginated = filtered.slice(startIndex, endIndex); - - // Format output - const content: TextContent[] = paginated.map(repo => { - const repoUrl = repo.webUrl ?? repo.repoCloneUrl; - return { - type: "text", - text: `id: ${repo.repoName}\nurl: ${repoUrl}`, - } - }); - - // Add pagination info - if (content.length === 0 && filtered.length > 0) { - content.push({ - type: "text", - text: `No results on page ${pageNumber}. Total matching repositories: ${filtered.length}`, - }); - } else if (filtered.length > endIndex) { - content.push({ - type: "text", - text: `Showing ${paginated.length} repositories (page ${pageNumber}). Total matching: ${filtered.length}. Use pageNumber ${pageNumber + 1} to see more.`, - }); - } + dedent`Lists repositories in the organization with optional filtering and pagination.`, + listReposQueryParamsSchema.shape, + async (request: ListReposQueryParams) => { + const result = await listRepos(request); return { - content, + content: [{ + type: "text", text: JSON.stringify({ + repos: result.repos.map((repo) => ({ + name: repo.repoName, + url: repo.webUrl, + pushedAt: repo.pushedAt, + })), + totalCount: result.totalCount, + }) + }] }; } ); server.tool( - "get_file_source", - "Fetches the source code for a given file. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.", - { - fileName: z.string().describe("The file to fetch the source code for."), - repoId: z.string().describe("The repository to fetch the source code for. This is the Sourcebot compatible repository ID."), - }, - async ({ fileName, repoId }) => { - const response = await getFileSource({ - fileName, - repository: repoId, - }); - - if (isServiceError(response)) { - return { - content: [{ - type: "text", - text: `Error fetching file source: ${response.message}`, - }], - }; - } - - const content: TextContent[] = [{ - type: "text", - text: `file: ${fileName}\nrepository: ${repoId}\nlanguage: ${response.language}\nsource:\n${response.source}`, - }] + "read_file", + dedent`Reads the source code for a given file.`, + fileSourceRequestSchema.shape, + async (request: FileSourceRequest) => { + const response = await getFileSource(request); return { - content, + content: [{ + type: "text", text: JSON.stringify({ + source: response.source, + language: response.language, + path: response.path, + url: response.webUrl, + }) + }] }; } ); diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts index 00d9877b8..fd89bdfbe 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -124,7 +124,8 @@ export const searchResponseSchema = z.object({ // Any matching ranges matchRanges: z.array(rangeSchema), }), - webUrl: z.string().optional(), + webUrl: z.string(), + externalWebUrl: z.string().optional(), repository: z.string(), repositoryId: z.number(), language: z.string(), @@ -150,42 +151,71 @@ export const repositoryQuerySchema = z.object({ repoId: z.number(), repoName: z.string(), repoDisplayName: z.string().optional(), - repoCloneUrl: z.string(), - webUrl: z.string().optional(), + webUrl: z.string(), + externalWebUrl: z.string().optional(), imageUrl: z.string().optional(), indexedAt: z.coerce.date().optional(), + pushedAt: z.coerce.date().optional(), }); -export const listRepositoriesResponseSchema = repositoryQuerySchema.array(); +export const listReposResponseSchema = repositoryQuerySchema.array(); -export const listReposRequestSchema = z.object({ +export const listReposQueryParamsSchema = z.object({ query: z .string() - .describe("Filter repositories by name or displayName (case-insensitive)") + .describe("Filter repositories by name (case-insensitive)") .optional(), - pageNumber: z + page: z .number() .int() .positive() - .describe("Page number (1-indexed, default: 1)") + .describe("Page number for pagination (min 1). Default: 1") + .optional() .default(1), - limit: z + perPage: z .number() .int() .positive() - .describe("Number of repositories per page (default: 50)") - .default(50), + .max(100) + .describe("Results per page for pagination (min 1, max 100). Default: 30") + .optional() + .default(30), + sort: z + .enum(['name', 'pushed']) + .describe("Sort repositories by 'name' or 'pushed' (most recent commit). Default: 'name'") + .optional() + .default('name'), + direction: z + .enum(['asc', 'desc']) + .describe("Sort direction: 'asc' or 'desc'. Default: 'asc'") + .optional() + .default('asc'), }); export const fileSourceRequestSchema = z.object({ - fileName: z.string(), - repository: z.string(), - branch: z.string().optional(), + repo: z + .string() + .describe("The repository name."), + path: z + .string() + .describe("The path to the file."), + ref: z + .string() + .optional() + .describe("Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch of the repository."), }); export const fileSourceResponseSchema = z.object({ source: z.string(), language: z.string(), + path: z.string(), + repo: z.string(), + repoCodeHostType: z.string(), + repoDisplayName: z.string().optional(), + repoExternalWebUrl: z.string().optional(), + branch: z.string().optional(), + webUrl: z.string(), + externalWebUrl: z.string().optional(), }); export const serviceErrorSchema = z.object({ @@ -194,16 +224,48 @@ export const serviceErrorSchema = z.object({ message: z.string(), }); -export const searchCommitsRequestSchema = z.object({ - repository: z.string(), - query: z.string().optional(), - since: z.string().optional(), - until: z.string().optional(), - author: z.string().optional(), - maxCount: z.number().int().positive().max(500).optional(), +export const listCommitsQueryParamsSchema = z.object({ + repo: z + .string() + .describe("The name of the repository to list commits for."), + query: z + .string() + .describe("Search query to filter commits by message content (case-insensitive).") + .optional(), + 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(), + 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(), + author: z + .string() + .describe(`Filter commits by author name or email (case-insensitive).`) + .optional(), + ref: z + .string() + .describe("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository.") + .optional(), + page: z + .number() + .int() + .positive() + .describe("Page number for pagination (min 1). Default: 1") + .optional() + .default(1), + perPage: z + .number() + .int() + .positive() + .max(100) + .describe("Results per page for pagination (min 1, max 100). Default: 50") + .optional() + .default(50), }); -export const searchCommitsResponseSchema = z.array(z.object({ +export const listCommitsResponseSchema = z.array(z.object({ hash: z.string(), date: z.string(), message: z.string(), diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 720867a8f..cd64cb085 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -2,7 +2,7 @@ // At some point, we should move these to a shared package... import { fileSourceResponseSchema, - listRepositoriesResponseSchema, + listReposQueryParamsSchema, locationSchema, searchRequestSchema, searchResponseSchema, @@ -10,8 +10,8 @@ import { fileSourceRequestSchema, symbolSchema, serviceErrorSchema, - searchCommitsRequestSchema, - searchCommitsResponseSchema, + listCommitsQueryParamsSchema, + listCommitsResponseSchema, } from "./schemas.js"; import { z } from "zod"; @@ -23,7 +23,7 @@ export type SearchResultFile = SearchResponse["files"][number]; export type SearchResultChunk = SearchResultFile["chunks"][number]; export type SearchSymbol = z.infer; -export type ListRepositoriesResponse = z.infer; +export type ListReposQueryParams = z.input; export type FileSourceRequest = z.infer; export type FileSourceResponse = z.infer; @@ -32,5 +32,5 @@ export type TextContent = { type: "text", text: string }; export type ServiceError = z.infer; -export type SearchCommitsRequest = z.infer; -export type SearchCommitsResponse = z.infer; +export type ListCommitsQueryParamsSchema = z.infer; +export type ListCommitsResponse = z.infer; diff --git a/packages/mcp/src/utils.ts b/packages/mcp/src/utils.ts index 77f1c54be..56e02f3e1 100644 --- a/packages/mcp/src/utils.ts +++ b/packages/mcp/src/utils.ts @@ -7,4 +7,10 @@ export const isServiceError = (data: unknown): data is ServiceError => { 'statusCode' in data && 'errorCode' in data && 'message' in data; -} \ No newline at end of file +} + +export class ServiceErrorException extends Error { + constructor(public readonly serviceError: ServiceError) { + super(JSON.stringify(serviceError)); + } +} diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 056a5fd6d..84ff76be3 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -31,6 +31,8 @@ import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLAS import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { ApiKeyPayload, TenancyMode } from "./lib/types"; import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; +import { getBaseUrl } from "./lib/utils.server"; +import { getBrowsePath } from "./app/[domain]/browse/hooks/utils"; const logger = createLogger('web-actions'); const auditService = getAuditService(); @@ -475,16 +477,25 @@ export const getRepos = async ({ }, take, }); + + const headersList = await headers(); + const baseUrl = getBaseUrl(headersList); return repos.map((repo) => repositoryQuerySchema.parse({ codeHostType: repo.external_codeHostType, repoId: repo.id, repoName: repo.name, repoDisplayName: repo.displayName ?? undefined, - repoCloneUrl: repo.cloneUrl, - webUrl: repo.webUrl ?? undefined, + webUrl: `${baseUrl}${getBrowsePath({ + repoName: repo.name, + path: '', + pathType: 'tree', + domain: org.domain, + })}`, + externalWebUrl: repo.webUrl ?? undefined, imageUrl: repo.imageUrl ?? undefined, indexedAt: repo.indexedAt ?? undefined, + pushedAt: repo.pushedAt ?? undefined, })) })); @@ -631,7 +642,7 @@ export const getRepoInfoByName = async (repoName: string) => sew(() => name: repo.name, displayName: repo.displayName ?? undefined, codeHostType: repo.external_codeHostType, - webUrl: repo.webUrl ?? undefined, + externalWebUrl: repo.webUrl ?? undefined, imageUrl: repo.imageUrl ?? undefined, indexedAt: repo.indexedAt ?? undefined, } diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx index 4e8a4da85..c4633dccc 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -34,13 +34,13 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre codeHostType: repoInfoResponse.codeHostType, name: repoInfoResponse.name, displayName: repoInfoResponse.displayName, - webUrl: repoInfoResponse.webUrl, + externalWebUrl: repoInfoResponse.externalWebUrl, }); // @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. THis // will break if the default branch is not main. - const fileWebUrl = repoInfoResponse.codeHostType === "azuredevops" && fileSourceResponse.webUrl ? - fileSourceResponse.webUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.webUrl; + const fileWebUrl = repoInfoResponse.codeHostType === "azuredevops" && fileSourceResponse.externalWebUrl ? + fileSourceResponse.externalWebUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.externalWebUrl; return ( <> @@ -51,7 +51,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre name: repoName, codeHostType: repoInfoResponse.codeHostType, displayName: repoInfoResponse.displayName, - webUrl: repoInfoResponse.webUrl, + externalWebUrl: repoInfoResponse.externalWebUrl, }} revisionName={revisionName} /> diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx index 7c39ac3cf..ada848eff 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx @@ -35,7 +35,7 @@ export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePre name: repoName, codeHostType: repoInfoResponse.codeHostType, displayName: repoInfoResponse.displayName, - webUrl: repoInfoResponse.webUrl, + externalWebUrl: repoInfoResponse.externalWebUrl, }} pathType="tree" isFileIconVisible={false} diff --git a/packages/web/src/app/[domain]/browse/hooks/utils.ts b/packages/web/src/app/[domain]/browse/hooks/utils.ts index 804c38643..b0aa3100d 100644 --- a/packages/web/src/app/[domain]/browse/hooks/utils.ts +++ b/packages/web/src/app/[domain]/browse/hooks/utils.ts @@ -64,7 +64,7 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => { }; export const getBrowsePath = ({ - repoName, revisionName = 'HEAD', path, pathType, highlightRange, setBrowseState, domain, + repoName, revisionName, path, pathType, highlightRange, setBrowseState, domain, }: GetBrowsePathProps) => { const params = new URLSearchParams(); @@ -83,7 +83,7 @@ export const getBrowsePath = ({ } const encodedPath = encodeURIComponent(path); - const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`; + const browsePath = `/${domain}/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`; return browsePath; }; diff --git a/packages/web/src/app/[domain]/components/navigationMenu/progressIndicator.tsx b/packages/web/src/app/[domain]/components/navigationMenu/progressIndicator.tsx index 71e8285a0..7726f635f 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu/progressIndicator.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu/progressIndicator.tsx @@ -87,7 +87,7 @@ const RepoItem = ({ repo }: { repo: RepositoryQuery }) => { name: repo.repoName, codeHostType: repo.codeHostType, displayName: repo.repoDisplayName, - webUrl: repo.webUrl, + externalWebUrl: repo.externalWebUrl, }); return { @@ -98,7 +98,7 @@ const RepoItem = ({ repo }: { repo: RepositoryQuery }) => { />, displayName: info.displayName, } - }, [repo.repoName, repo.codeHostType, repo.repoDisplayName, repo.webUrl]); + }, [repo.repoName, repo.codeHostType, repo.repoDisplayName, repo.externalWebUrl]); return ( diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx index f5ac79cac..2b4298e22 100644 --- a/packages/web/src/app/[domain]/components/pathHeader.tsx +++ b/packages/web/src/app/[domain]/components/pathHeader.tsx @@ -29,7 +29,7 @@ interface FileHeaderProps { name: string; codeHostType: CodeHostType; displayName?: string; - webUrl?: string; + externalWebUrl?: string; }, isBranchDisplayNameVisible?: boolean; branchDisplayName?: string; @@ -67,7 +67,7 @@ export const PathHeader = ({ name: repo.name, codeHostType: repo.codeHostType, displayName: repo.displayName, - webUrl: repo.webUrl, + externalWebUrl: repo.externalWebUrl, }); const { toast } = useToast(); @@ -206,7 +206,7 @@ export const PathHeader = ({
{isCodeHostIconVisible && ( <> - + {info.codeHostName} { - const domain = useDomain(); const { repoIcon, displayName } = (() => { const info = getCodeHostInfoForRepo({ codeHostType: repo.codeHostType, name: repo.repoName, displayName: repo.repoDisplayName, - webUrl: repo.webUrl, + externalWebUrl: repo.externalWebUrl, }); return { @@ -132,13 +130,7 @@ const RepositoryBadge = ({ return ( {repoIcon} diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index 2bd82812d..92b6b7168 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -155,6 +155,7 @@ const SearchSuggestionsBox = forwardRef(({ list: repoSuggestions, DefaultIcon: VscRepo, onSuggestionClicked: createOnSuggestionClickedHandler({ regexEscaped: true }), + isClientSideSearchEnabled: false, } case "language": { return { diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index 79392854b..b2a372462 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; -import { getRepos, search } from "@/app/api/(client)/client"; +import { listRepos, search } from "@/app/api/(client)/client"; import { getSearchContexts } from "@/actions"; import { useMemo } from "react"; import { SearchSymbol } from "@/features/search"; @@ -37,8 +37,14 @@ export const useSuggestionsData = ({ }: Props) => { const domain = useDomain(); const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({ - queryKey: ["repoSuggestions"], - queryFn: () => unwrapServiceError(getRepos()), + queryKey: ["repoSuggestions", suggestionQuery], + queryFn: () => unwrapServiceError(listRepos({ + page: 1, + direction: "asc", + sort: "name", + perPage: 15, + query: suggestionQuery, + })), select: (data): Suggestion[] => { return data .map(r => ({ diff --git a/packages/web/src/app/[domain]/repos/[id]/page.tsx b/packages/web/src/app/[domain]/repos/[id]/page.tsx index 0c2ddfa1a..a62ade70e 100644 --- a/packages/web/src/app/[domain]/repos/[id]/page.tsx +++ b/packages/web/src/app/[domain]/repos/[id]/page.tsx @@ -32,7 +32,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: codeHostType: repo.external_codeHostType, name: repo.name, displayName: repo.displayName ?? undefined, - webUrl: repo.webUrl ?? undefined, + externalWebUrl: repo.webUrl ?? undefined, }); const configSettings = await getConfigSettings(env.CONFIG_PATH); @@ -71,9 +71,9 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:

{repo.displayName || repo.name}

{repo.name}

- {codeHostInfo.repoLink && ( + {codeHostInfo.externalWebUrl && (