From a3e76a0da67ee79668a4be47e029b08d6d703ac1 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 15:16:26 -0800 Subject: [PATCH 01/16] wip --- packages/web/src/actions.ts | 1 + .../web/src/app/api/(server)/repos/route.ts | 83 +++++++++++++++++-- packages/web/src/lib/pagination.ts | 48 +++++++++++ packages/web/src/lib/schemas.ts | 1 + 4 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/lib/pagination.ts diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 056a5fd6d..e193c614f 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -485,6 +485,7 @@ export const getRepos = async ({ webUrl: repo.webUrl ?? undefined, imageUrl: repo.imageUrl ?? undefined, indexedAt: repo.indexedAt ?? undefined, + pushedAt: repo.pushedAt ?? undefined, })) })); diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index acc3f9ce0..851285f16 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -1,13 +1,82 @@ -import { getRepos } from "@/actions"; -import { serviceErrorResponse } from "@/lib/serviceError"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { sew } from "@/actions"; +import { withOptionalAuthV2 } from "@/withAuthV2"; +import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; -import { GetReposResponse } from "@/lib/types"; +import { repositoryQuerySchema } from "@/lib/schemas"; +import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; +const querySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + per_page: z.coerce.number().int().positive().max(100).default(30), + sort: z.enum(['name', 'pushed']).default('name'), + direction: z.enum(['asc', 'desc']).default('asc'), +}); + +export const GET = async (request: NextRequest) => { + const parseResult = querySchema.safeParse({ + page: request.nextUrl.searchParams.get('page') ?? undefined, + per_page: request.nextUrl.searchParams.get('per_page') ?? undefined, + sort: request.nextUrl.searchParams.get('sort') ?? undefined, + direction: request.nextUrl.searchParams.get('direction') ?? undefined, + }); + + if (!parseResult.success) { + return serviceErrorResponse(schemaValidationError(parseResult.error)); + } + + const { page, per_page: perPage, sort, direction } = parseResult.data; + const skip = (page - 1) * perPage; + const orderByField = sort === 'pushed' ? 'pushedAt' : 'displayName'; + + const response = await sew(() => + withOptionalAuthV2(async ({ org, prisma }) => { + const [repos, totalCount] = await Promise.all([ + prisma.repo.findMany({ + where: { orgId: org.id }, + skip, + take: perPage, + orderBy: { [orderByField]: direction }, + }), + prisma.repo.count({ + where: { orgId: org.id }, + }), + ]); + + return { + data: 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, + imageUrl: repo.imageUrl ?? undefined, + indexedAt: repo.indexedAt ?? undefined, + pushedAt: repo.pushedAt ?? undefined, + })), + totalCount, + }; + }) + ); -export const GET = async () => { - const response: GetReposResponse = await getRepos(); if (isServiceError(response)) { return serviceErrorResponse(response); } - return Response.json(response); -} + + const { data, totalCount } = response; + + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('X-Total-Count', totalCount.toString()); + + const linkHeader = buildLinkHeader(getBaseUrl(request), { + page, + perPage, + totalCount, + extraParams: { sort, direction }, + }); + if (linkHeader) headers.set('Link', linkHeader); + + return new Response(JSON.stringify(data), { status: 200, headers }); +}; diff --git a/packages/web/src/lib/pagination.ts b/packages/web/src/lib/pagination.ts new file mode 100644 index 000000000..80199c3da --- /dev/null +++ b/packages/web/src/lib/pagination.ts @@ -0,0 +1,48 @@ +import { NextRequest } from "next/server"; + +export interface PaginationParams { + page: number; + perPage: number; + totalCount: number; + extraParams?: Record; +} + +/** + * Build RFC 5988 Link header value + * @see https://datatracker.ietf.org/doc/html/rfc5988 + */ +export const buildLinkHeader = (baseUrl: string, params: PaginationParams): string | null => { + const { page, perPage, totalCount, extraParams } = params; + const totalPages = Math.ceil(totalCount / perPage); + + if (totalPages <= 1) return null; + + const buildUrl = (targetPage: number): string => { + const url = new URL(baseUrl); + url.searchParams.set('page', targetPage.toString()); + url.searchParams.set('per_page', perPage.toString()); + if (extraParams) { + for (const [key, value] of Object.entries(extraParams)) { + url.searchParams.set(key, value); + } + } + return url.toString(); + }; + + const links: string[] = []; + links.push(`<${buildUrl(1)}>; rel="first"`); + if (page > 1) links.push(`<${buildUrl(page - 1)}>; rel="prev"`); + if (page < totalPages) links.push(`<${buildUrl(page + 1)}>; rel="next"`); + links.push(`<${buildUrl(totalPages)}>; rel="last"`); + + return links.join(', '); +}; + +/** + * Extract base URL from request (without query params) + */ +export const getBaseUrl = (request: NextRequest): string => { + const url = new URL(request.url); + url.search = ''; + return url.toString(); +}; diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index a12eaf2f5..1e927883e 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -22,6 +22,7 @@ export const repositoryQuerySchema = z.object({ webUrl: z.string().optional(), imageUrl: z.string().optional(), indexedAt: z.coerce.date().optional(), + pushedAt: z.coerce.date().optional(), }); export const searchContextQuerySchema = z.object({ From 0b13ee4ad8c05b628e58701cb4d662e41c191f6a Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 17:44:20 -0800 Subject: [PATCH 02/16] wip --- packages/mcp/package.json | 1 + packages/mcp/src/client.ts | 96 +++++++----- packages/mcp/src/index.ts | 145 ++++-------------- packages/mcp/src/schemas.ts | 35 +++-- packages/mcp/src/types.ts | 10 +- packages/mcp/src/utils.ts | 6 + .../web/src/app/api/(server)/repos/route.ts | 13 +- yarn.lock | 13 ++ 8 files changed, 156 insertions(+), 163 deletions(-) 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..722fbcc13 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,60 +1,90 @@ 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 { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema } from './schemas.js'; +import { FileSourceRequest, ListReposRequest, SearchRequest, ListCommitsRequestSchema } 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`, { +export interface ListReposResult { + repos: z.infer; + totalCount: number; +} + +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 (request: ListReposRequest = {}) => { + const url = new URL(`${env.SOURCEBOT_HOST}/api/repos`); + + 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 } : {}) }, - }).then(response => response.json()); - - if (isServiceError(result)) { - return result; - } + }); - return listRepositoriesResponseSchema.parse(result); + const repos = await parseResponse(response, listRepositoriesResponseSchema); + 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`, { +export const getFileSource = async (request: FileSourceRequest) => { + const response = await fetch(`${env.SOURCEBOT_HOST}/api/source`, { 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 fileSourceResponseSchema.parse(result); + return parseResponse(response, fileSourceResponseSchema); } -export const searchCommits = async (request: SearchCommitsRequest): Promise => { - const result = await fetch(`${env.SOURCEBOT_HOST}/api/commits`, { +export const listCommits = async (request: ListCommitsRequestSchema) => { + const response = await fetch(`${env.SOURCEBOT_HOST}/api/commits`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -62,11 +92,7 @@ export const searchCommits = async (request: SearchCommitsRequest): Promise response.json()); - - if (isServiceError(result)) { - return result; - } + }); - return searchCommitsResponseSchema.parse(result); + return parseResponse(response, listCommitsResponseSchema); } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d30c79cef..eda8b60bc 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -5,11 +5,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import escapeStringRegexp from 'escape-string-regexp'; import { z } from 'zod'; -import { getFileSource, listRepos, search, searchCommits } from './client.js'; +import { getFileSource, listRepos, search, listCommits } 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 { ListReposRequest, TextContent } from './types.js'; +import _dedent from "dedent"; + +const dedent = _dedent.withOptions({ alignValues: true }); // Create MCP server const server = new McpServer({ @@ -20,9 +22,11 @@ const server = new McpServer({ server.tool( "search_code", - `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search. + dedent` + Fetches code that matches the provided regex pattern in \`query\`. + 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.`, @@ -88,15 +92,6 @@ server.tool( source: 'mcp', }); - if (isServiceError(response)) { - return { - content: [{ - type: "text", - text: `Error searching code: ${response.message}`, - }], - }; - } - if (response.files.length === 0) { return { content: [{ @@ -116,7 +111,12 @@ server.tool( 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: ${fileIdentifier} + num_matches: ${numMatches} + repository: ${file.repository} + language: ${file.language} + `; if (includeCodeSnippets) { const snippets = file.chunks.map(chunk => { @@ -172,19 +172,19 @@ 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.`, + "list_commits", + dedent`Get a list of commits for a given repository.`, { - repoId: z.string().describe(`The repository to search commits in. This is the Sourcebot compatible repository ID as returned by 'list_repos'.`), + repoName: z.string().describe(`The name of the repository to search commits in.`), 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).`), + author: z.string().describe(`Filter commits by author name or email`).optional(), + maxCount: z.number().int().positive().max(100).default(50).describe(`Maximum number of commits to return (default: 50).`), }, - async ({ repoId, query, since, until, author, maxCount }) => { - const result = await searchCommits({ - repository: repoId, + async ({ repoName, query, since, until, author, maxCount }) => { + const result = await listCommits({ + repository: repoName, query, since, until, @@ -192,114 +192,37 @@ server.tool( maxCount, }); - if (isServiceError(result)) { - return { - content: [{ type: "text", text: `Error: ${result.message}` }], - isError: true, - }; - } - 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.`, + dedent`Lists repositories in the organization with optional filtering and pagination.`, 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}`, - }], - }; - } + async ({ query, page = 1, perPage = 30, sort = 'name', direction = 'asc' }: ListReposRequest) => { + const result = await listRepos({ query, page, perPage, sort, direction }); - // 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.`, - }); - } - - return { - content, - }; + return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); 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.", + "read_file", + dedent`Reads the source code for a given file.`, { 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."), + repoName: z.string().describe("The name of the repository to fetch the source code for."), }, - async ({ fileName, repoId }) => { + async ({ fileName, repoName }) => { const response = await getFileSource({ fileName, - repository: repoId, + repository: repoName, }); - 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}`, - }] - - return { - content, - }; + return { content: [{ type: "text", text: JSON.stringify(response) }] }; } ); diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts index 00d9877b8..7ce43d0e7 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -161,24 +161,39 @@ export const listRepositoriesResponseSchema = repositoryQuerySchema.array(); export const listReposRequestSchema = 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(), + fileName: z + .string() + .describe("The name of the file to fetch the source code for."), repository: z.string(), branch: z.string().optional(), }); @@ -194,16 +209,16 @@ export const serviceErrorSchema = z.object({ message: z.string(), }); -export const searchCommitsRequestSchema = z.object({ +export const listCommitsRequestSchema = 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(), + maxCount: z.number().int().positive().max(100).optional(), }); -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..4b020b050 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -3,6 +3,7 @@ import { fileSourceResponseSchema, listRepositoriesResponseSchema, + listReposRequestSchema, locationSchema, searchRequestSchema, searchResponseSchema, @@ -10,8 +11,8 @@ import { fileSourceRequestSchema, symbolSchema, serviceErrorSchema, - searchCommitsRequestSchema, - searchCommitsResponseSchema, + listCommitsRequestSchema, + listCommitsResponseSchema, } from "./schemas.js"; import { z } from "zod"; @@ -24,6 +25,7 @@ export type SearchResultChunk = SearchResultFile["chunks"][number]; export type SearchSymbol = z.infer; export type ListRepositoriesResponse = z.infer; +export type ListReposRequest = z.input; export type FileSourceRequest = z.infer; export type FileSourceResponse = z.infer; @@ -32,5 +34,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 ListCommitsRequestSchema = z.infer; +export type ListCommitsResponse = z.infer; diff --git a/packages/mcp/src/utils.ts b/packages/mcp/src/utils.ts index 77f1c54be..1cfd32d8c 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; +} + +export class ServiceErrorException extends Error { + constructor(public readonly serviceError: ServiceError) { + super(JSON.stringify(serviceError)); + } } \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 851285f16..e71e056e7 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -12,6 +12,7 @@ const querySchema = z.object({ per_page: z.coerce.number().int().positive().max(100).default(30), sort: z.enum(['name', 'pushed']).default('name'), direction: z.enum(['asc', 'desc']).default('asc'), + query: z.string().optional(), }); export const GET = async (request: NextRequest) => { @@ -20,21 +21,27 @@ export const GET = async (request: NextRequest) => { per_page: request.nextUrl.searchParams.get('per_page') ?? undefined, sort: request.nextUrl.searchParams.get('sort') ?? undefined, direction: request.nextUrl.searchParams.get('direction') ?? undefined, + query: request.nextUrl.searchParams.get('query') ?? undefined, }); if (!parseResult.success) { return serviceErrorResponse(schemaValidationError(parseResult.error)); } - const { page, per_page: perPage, sort, direction } = parseResult.data; + const { page, per_page: perPage, sort, direction, query } = parseResult.data; const skip = (page - 1) * perPage; - const orderByField = sort === 'pushed' ? 'pushedAt' : 'displayName'; + const orderByField = sort === 'pushed' ? 'pushedAt' : 'name'; const response = await sew(() => withOptionalAuthV2(async ({ org, prisma }) => { const [repos, totalCount] = await Promise.all([ prisma.repo.findMany({ - where: { orgId: org.id }, + where: { + orgId: org.id, + ...(query ? { + name: { contains: query, mode: 'insensitive' }, + } : {}), + }, skip, take: perPage, orderBy: { [orderByField]: direction }, diff --git a/yarn.lock b/yarn.lock index 49b180d47..f1d908481 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8178,6 +8178,7 @@ __metadata: "@t3-oss/env-core": "npm:^0.13.4" "@types/express": "npm:^5.0.1" "@types/node": "npm:^20.0.0" + dedent: "npm:^1.7.1" escape-string-regexp: "npm:^5.0.0" express: "npm:^5.1.0" tsc-watch: "npm:6.2.1" @@ -11417,6 +11418,18 @@ __metadata: languageName: node linkType: hard +"dedent@npm:^1.7.1": + version: 1.7.1 + resolution: "dedent@npm:1.7.1" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10c0/ae29ec1c5bd5216c698c9f23acaa5b720260fd4cef3c8b5af887eb5f8c9e6fdd5fed8668767437b4efea35e2991bd798987717633411a1734807c28255769b78 + languageName: node + linkType: hard + "deep-eql@npm:^5.0.1": version: 5.0.2 resolution: "deep-eql@npm:5.0.2" From 96a64b015022b6916182ba0c741601141a49f20c Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 18:17:30 -0800 Subject: [PATCH 03/16] change commits route to GET request with query params --- packages/mcp/src/client.ts | 13 ++++++--- .../web/src/app/api/(server)/chat/route.ts | 4 +-- .../web/src/app/api/(server)/commits/route.ts | 27 ++++++++++++++----- .../web/src/app/api/(server)/files/route.ts | 4 +-- .../api/(server)/find_definitions/route.ts | 4 +-- .../app/api/(server)/find_references/route.ts | 4 +-- .../web/src/app/api/(server)/repos/route.ts | 4 +-- .../web/src/app/api/(server)/search/route.ts | 4 +-- .../web/src/app/api/(server)/source/route.ts | 4 +-- .../app/api/(server)/stream_search/route.ts | 4 +-- .../web/src/app/api/(server)/tree/route.ts | 4 +-- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/serviceError.ts | 10 ++++++- 13 files changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 722fbcc13..d9911bbd2 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -84,14 +84,19 @@ export const getFileSource = async (request: FileSourceRequest) => { } export const listCommits = async (request: ListCommitsRequestSchema) => { - const response = await fetch(`${env.SOURCEBOT_HOST}/api/commits`, { - method: 'POST', + const url = new URL(`${env.SOURCEBOT_HOST}/api/commits`); + 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', 'X-Org-Domain': '~', ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) }, - body: JSON.stringify(request) }); return parseResponse(response, listCommitsResponseSchema); diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index e0fc2b8bf..2874c48f2 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -4,7 +4,7 @@ import { createAgentStream } from "@/features/chat/agent"; import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types"; import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { notFound, requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { withOptionalAuthV2 } from "@/withAuthV2"; import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider"; @@ -37,7 +37,7 @@ export async function POST(req: Request) { const requestBody = await req.json(); const parsed = await chatRequestSchema.safeParseAsync(requestBody); if (!parsed.success) { - return serviceErrorResponse(schemaValidationError(parsed.error)); + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } const { messages, id, selectedSearchScopes, languageModel: _languageModel } = parsed.data; diff --git a/packages/web/src/app/api/(server)/commits/route.ts b/packages/web/src/app/api/(server)/commits/route.ts index 941ca8605..0d9394a04 100644 --- a/packages/web/src/app/api/(server)/commits/route.ts +++ b/packages/web/src/app/api/(server)/commits/route.ts @@ -1,16 +1,31 @@ import { searchCommits } from "@/features/search/gitApi"; -import { serviceErrorResponse, schemaValidationError } from "@/lib/serviceError"; +import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { searchCommitsRequestSchema } from "@/features/search/types"; +import { z } from "zod"; -export async function POST(request: NextRequest): Promise { - const body = await request.json(); - const parsed = await searchCommitsRequestSchema.safeParseAsync(body); +const querySchema = z.object({ + repository: z.string(), + query: z.string().optional(), + since: z.string().optional(), + until: z.string().optional(), + author: z.string().optional(), + maxCount: z.coerce.number().int().positive().max(500).optional(), +}); + +export const GET = async (request: NextRequest): Promise => { + const parsed = querySchema.safeParse({ + repository: request.nextUrl.searchParams.get('repository') ?? undefined, + query: request.nextUrl.searchParams.get('query') ?? undefined, + since: request.nextUrl.searchParams.get('since') ?? undefined, + until: request.nextUrl.searchParams.get('until') ?? undefined, + author: request.nextUrl.searchParams.get('author') ?? undefined, + maxCount: request.nextUrl.searchParams.get('maxCount') ?? undefined, + }); if (!parsed.success) { return serviceErrorResponse( - schemaValidationError(parsed.error) + queryParamsSchemaValidationError(parsed.error) ); } diff --git a/packages/web/src/app/api/(server)/files/route.ts b/packages/web/src/app/api/(server)/files/route.ts index 70d1330b9..1b64efec2 100644 --- a/packages/web/src/app/api/(server)/files/route.ts +++ b/packages/web/src/app/api/(server)/files/route.ts @@ -2,7 +2,7 @@ import { getFiles } from "@/features/fileTree/api"; import { getFilesRequestSchema } from "@/features/fileTree/types"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; @@ -10,7 +10,7 @@ export const POST = async (request: NextRequest) => { const body = await request.json(); const parsed = await getFilesRequestSchema.safeParseAsync(body); if (!parsed.success) { - return serviceErrorResponse(schemaValidationError(parsed.error)); + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } const response = await getFiles(parsed.data); diff --git a/packages/web/src/app/api/(server)/find_definitions/route.ts b/packages/web/src/app/api/(server)/find_definitions/route.ts index d8abfa38d..1ad524957 100644 --- a/packages/web/src/app/api/(server)/find_definitions/route.ts +++ b/packages/web/src/app/api/(server)/find_definitions/route.ts @@ -2,7 +2,7 @@ import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/api"; import { findRelatedSymbolsRequestSchema } from "@/features/codeNav/types"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; @@ -10,7 +10,7 @@ export const POST = async (request: NextRequest) => { const body = await request.json(); const parsed = await findRelatedSymbolsRequestSchema.safeParseAsync(body); if (!parsed.success) { - return serviceErrorResponse(schemaValidationError(parsed.error)); + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } const response = await findSearchBasedSymbolDefinitions(parsed.data); diff --git a/packages/web/src/app/api/(server)/find_references/route.ts b/packages/web/src/app/api/(server)/find_references/route.ts index 4e4b729bb..a37ae8005 100644 --- a/packages/web/src/app/api/(server)/find_references/route.ts +++ b/packages/web/src/app/api/(server)/find_references/route.ts @@ -1,6 +1,6 @@ import { findSearchBasedSymbolReferences } from "@/features/codeNav/api"; import { findRelatedSymbolsRequestSchema } from "@/features/codeNav/types"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; @@ -8,7 +8,7 @@ export const POST = async (request: NextRequest) => { const body = await request.json(); const parsed = await findRelatedSymbolsRequestSchema.safeParseAsync(body); if (!parsed.success) { - return serviceErrorResponse(schemaValidationError(parsed.error)); + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } const response = await findSearchBasedSymbolReferences(parsed.data); diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index e71e056e7..fe585558b 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; import { sew } from "@/actions"; import { withOptionalAuthV2 } from "@/withAuthV2"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { repositoryQuerySchema } from "@/lib/schemas"; import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; @@ -25,7 +25,7 @@ export const GET = async (request: NextRequest) => { }); if (!parseResult.success) { - return serviceErrorResponse(schemaValidationError(parseResult.error)); + return serviceErrorResponse(queryParamsSchemaValidationError(parseResult.error)); } const { page, per_page: perPage, sort, direction, query } = parseResult.data; diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index e215d3c33..a3fb565f6 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -3,7 +3,7 @@ import { search, searchRequestSchema } from "@/features/search"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { captureEvent } from "@/lib/posthog"; export const POST = async (request: NextRequest) => { @@ -11,7 +11,7 @@ export const POST = async (request: NextRequest) => { const parsed = await searchRequestSchema.safeParseAsync(body); if (!parsed.success) { return serviceErrorResponse( - schemaValidationError(parsed.error) + requestBodySchemaValidationError(parsed.error) ); } diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 2fb785a85..07c5b958b 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -1,7 +1,7 @@ 'use server'; import { getFileSource } from "@/features/search/fileSourceApi"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; import { fileSourceRequestSchema } from "@/features/search/types"; @@ -11,7 +11,7 @@ export const POST = async (request: NextRequest) => { const parsed = await fileSourceRequestSchema.safeParseAsync(body); if (!parsed.success) { return serviceErrorResponse( - schemaValidationError(parsed.error) + requestBodySchemaValidationError(parsed.error) ); } diff --git a/packages/web/src/app/api/(server)/stream_search/route.ts b/packages/web/src/app/api/(server)/stream_search/route.ts index 47f1426be..d698517cb 100644 --- a/packages/web/src/app/api/(server)/stream_search/route.ts +++ b/packages/web/src/app/api/(server)/stream_search/route.ts @@ -2,7 +2,7 @@ import { streamSearch, searchRequestSchema } from '@/features/search'; import { captureEvent } from '@/lib/posthog'; -import { schemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; +import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; import { isServiceError } from '@/lib/utils'; import { NextRequest } from 'next/server'; @@ -11,7 +11,7 @@ export const POST = async (request: NextRequest) => { const parsed = await searchRequestSchema.safeParseAsync(body); if (!parsed.success) { - return serviceErrorResponse(schemaValidationError(parsed.error)); + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } const { diff --git a/packages/web/src/app/api/(server)/tree/route.ts b/packages/web/src/app/api/(server)/tree/route.ts index efe63bffe..6f2d22530 100644 --- a/packages/web/src/app/api/(server)/tree/route.ts +++ b/packages/web/src/app/api/(server)/tree/route.ts @@ -2,7 +2,7 @@ import { getTree } from "@/features/fileTree/api"; import { getTreeRequestSchema } from "@/features/fileTree/types"; -import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; @@ -10,7 +10,7 @@ export const POST = async (request: NextRequest) => { const body = await request.json(); const parsed = await getTreeRequestSchema.safeParseAsync(body); if (!parsed.success) { - return serviceErrorResponse(schemaValidationError(parsed.error)); + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); } const response = await getTree(parsed.data); diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index fc2abbc0c..e0e8c68b2 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -5,6 +5,7 @@ export enum ErrorCode { REPOSITORY_NOT_FOUND = 'REPOSITORY_NOT_FOUND', FILE_NOT_FOUND = 'FILE_NOT_FOUND', INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY', + INVALID_QUERY_PARAMS = 'INVALID_QUERY_PARAMS', NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', NOT_FOUND = 'NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND', diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index c942df079..6d8a19f17 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -37,7 +37,7 @@ export const missingQueryParam = (name: string): ServiceError => { }; } -export const schemaValidationError = (error: ZodError): ServiceError => { +export const requestBodySchemaValidationError = (error: ZodError): ServiceError => { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, @@ -45,6 +45,14 @@ export const schemaValidationError = (error: ZodError): ServiceError => { }; } +export const queryParamsSchemaValidationError = (error: ZodError): ServiceError => { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_QUERY_PARAMS, + message: `Query params validation failed with: ${error.message}`, + }; +} + export const invalidZoektResponse = async (zoektResponse: Response): Promise => { const zoektMessage = await (async () => { try { From 493a2f645fdbc9331ba38eb9030b15b46c8d19a5 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 19:41:07 -0800 Subject: [PATCH 04/16] paginated git api --- packages/mcp/src/client.ts | 4 +- packages/mcp/src/schemas.ts | 2 + .../web/src/app/api/(server)/commits/route.ts | 33 +++++++++++-- .../web/src/app/api/(server)/repos/route.ts | 6 +-- packages/web/src/features/search/gitApi.ts | 49 +++++++++++-------- packages/web/src/features/search/types.ts | 1 + packages/web/src/lib/pagination.ts | 2 +- 7 files changed, 68 insertions(+), 29 deletions(-) diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index d9911bbd2..05b69a463 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -99,5 +99,7 @@ export const listCommits = async (request: ListCommitsRequestSchema) => { }, }); - return parseResponse(response, listCommitsResponseSchema); + 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/schemas.ts b/packages/mcp/src/schemas.ts index 7ce43d0e7..f1f97d10a 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -216,6 +216,8 @@ export const listCommitsRequestSchema = z.object({ until: z.string().optional(), author: z.string().optional(), maxCount: z.number().int().positive().max(100).optional(), + page: z.number().int().positive().optional(), + perPage: z.number().int().positive().max(100).optional(), }); export const listCommitsResponseSchema = z.array(z.object({ diff --git a/packages/web/src/app/api/(server)/commits/route.ts b/packages/web/src/app/api/(server)/commits/route.ts index 0d9394a04..fa9379aa9 100644 --- a/packages/web/src/app/api/(server)/commits/route.ts +++ b/packages/web/src/app/api/(server)/commits/route.ts @@ -1,4 +1,5 @@ import { searchCommits } from "@/features/search/gitApi"; +import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; @@ -10,7 +11,8 @@ const querySchema = z.object({ since: z.string().optional(), until: z.string().optional(), author: z.string().optional(), - maxCount: z.coerce.number().int().positive().max(500).optional(), + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(50), }); export const GET = async (request: NextRequest): Promise => { @@ -20,7 +22,8 @@ export const GET = async (request: NextRequest): Promise => { since: request.nextUrl.searchParams.get('since') ?? undefined, until: request.nextUrl.searchParams.get('until') ?? undefined, author: request.nextUrl.searchParams.get('author') ?? undefined, - maxCount: request.nextUrl.searchParams.get('maxCount') ?? undefined, + page: request.nextUrl.searchParams.get('page') ?? undefined, + perPage: request.nextUrl.searchParams.get('perPage') ?? undefined, }); if (!parsed.success) { @@ -29,11 +32,33 @@ export const GET = async (request: NextRequest): Promise => { ); } - const result = await searchCommits(parsed.data); + const { page, perPage, ...searchParams } = parsed.data; + const skip = (page - 1) * perPage; + + const result = await searchCommits({ ...searchParams, maxCount: perPage, skip }); if (isServiceError(result)) { return serviceErrorResponse(result); } - return Response.json(result); + const { commits, totalCount } = result; + + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('X-Total-Count', totalCount.toString()); + + const linkHeader = buildLinkHeader(getBaseUrl(request), { + page, + perPage, + totalCount, + extraParams: { + repository: searchParams.repository, + ...(searchParams.query && { query: searchParams.query }), + ...(searchParams.since && { since: searchParams.since }), + ...(searchParams.until && { until: searchParams.until }), + ...(searchParams.author && { author: searchParams.author }), + }, + }); + if (linkHeader) headers.set('Link', linkHeader); + + return new Response(JSON.stringify(commits), { status: 200, headers }); } diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index fe585558b..7fe75549e 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -9,7 +9,7 @@ import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; const querySchema = z.object({ page: z.coerce.number().int().positive().default(1), - per_page: z.coerce.number().int().positive().max(100).default(30), + perPage: z.coerce.number().int().positive().max(100).default(30), sort: z.enum(['name', 'pushed']).default('name'), direction: z.enum(['asc', 'desc']).default('asc'), query: z.string().optional(), @@ -18,7 +18,7 @@ const querySchema = z.object({ export const GET = async (request: NextRequest) => { const parseResult = querySchema.safeParse({ page: request.nextUrl.searchParams.get('page') ?? undefined, - per_page: request.nextUrl.searchParams.get('per_page') ?? undefined, + perPage: request.nextUrl.searchParams.get('perPage') ?? undefined, sort: request.nextUrl.searchParams.get('sort') ?? undefined, direction: request.nextUrl.searchParams.get('direction') ?? undefined, query: request.nextUrl.searchParams.get('query') ?? undefined, @@ -28,7 +28,7 @@ export const GET = async (request: NextRequest) => { return serviceErrorResponse(queryParamsSchemaValidationError(parseResult.error)); } - const { page, per_page: perPage, sort, direction, query } = parseResult.data; + const { page, perPage, sort, direction, query } = parseResult.data; const skip = (page - 1) * perPage; const orderByField = sort === 'pushed' ? 'pushedAt' : 'name'; diff --git a/packages/web/src/features/search/gitApi.ts b/packages/web/src/features/search/gitApi.ts index d1274ba6a..ee6ddb7c5 100644 --- a/packages/web/src/features/search/gitApi.ts +++ b/packages/web/src/features/search/gitApi.ts @@ -16,6 +16,11 @@ export interface Commit { author_email: string; } +export interface SearchCommitsResult { + commits: Commit[]; + totalCount: number; +} + /** * Search commits in a repository using git log. * @@ -30,7 +35,8 @@ export const searchCommits = async ({ until, author, maxCount = 50, -}: SearchCommitsRequest): Promise => sew(() => + skip = 0, +}: SearchCommitsRequest): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma }) => { const repo = await prisma.repo.findFirst({ where: { @@ -42,7 +48,7 @@ export const searchCommits = async ({ if (!repo) { return notFound(`Repository "${repository}" not found.`); } - + const { path: repoPath } = getRepoPath(repo); // Validate date range if both since and until are provided @@ -58,29 +64,32 @@ export const searchCommits = async ({ const git = simpleGit().cwd(repoPath); try { - const logOptions: Record = { - maxCount, + const sharedOptions: Record = { + ...(gitSince ? { '--since': gitSince } : {}), + ...(gitUntil ? { '--until': gitUntil } : {}), + ...(author ? { '--author': author } : {}), + ...(query ? { + '--grep': query, + '--regexp-ignore-case': null /// Case insensitive + } : {}), }; - if (gitSince) { - logOptions['--since'] = gitSince; - } - - if (gitUntil) { - logOptions['--until'] = gitUntil; - } - - if (author) { - logOptions['--author'] = author; + // First, get the commits + const log = await git.log({ + maxCount, + ...(skip > 0 ? { '--skip': skip } : {}), + ...sharedOptions, + }); + + // Then, use rev-list to get the total count of commits + const countArgs = ['rev-list', '--count', 'HEAD']; + for (const [key, value] of Object.entries(sharedOptions)) { + countArgs.push(value !== null ? `${key}=${value}` : key); } - if (query) { - logOptions['--grep'] = query; - logOptions['--regexp-ignore-case'] = null; // Case insensitive - } + const totalCount = parseInt((await git.raw(countArgs)).trim(), 10); - const log = await git.log(logOptions); - return log.all as unknown as Commit[]; + return { commits: log.all as unknown as Commit[], totalCount }; } catch (error: unknown) { // Provide detailed error messages for common git errors const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts index 65a32a7a3..901eeb19b 100644 --- a/packages/web/src/features/search/types.ts +++ b/packages/web/src/features/search/types.ts @@ -171,5 +171,6 @@ export const searchCommitsRequestSchema = z.object({ until: z.string().optional(), author: z.string().optional(), maxCount: z.number().int().positive().max(500).optional(), + skip: z.number().int().nonnegative().optional(), }); export type SearchCommitsRequest = z.infer; diff --git a/packages/web/src/lib/pagination.ts b/packages/web/src/lib/pagination.ts index 80199c3da..94623d82c 100644 --- a/packages/web/src/lib/pagination.ts +++ b/packages/web/src/lib/pagination.ts @@ -20,7 +20,7 @@ export const buildLinkHeader = (baseUrl: string, params: PaginationParams): stri const buildUrl = (targetPage: number): string => { const url = new URL(baseUrl); url.searchParams.set('page', targetPage.toString()); - url.searchParams.set('per_page', perPage.toString()); + url.searchParams.set('perPage', perPage.toString()); if (extraParams) { for (const [key, value] of Object.entries(extraParams)) { url.searchParams.set(key, value); From 4e2e14cab2e4d8b64a1d23a681211f692958a897 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 21:23:09 -0800 Subject: [PATCH 05/16] add param to list commits endpoint --- packages/mcp/src/index.ts | 28 ++--- packages/mcp/src/schemas.ts | 48 ++++++-- packages/mcp/src/types.ts | 4 +- .../web/src/app/api/(server)/commits/route.ts | 28 ++--- .../web/src/app/api/(server)/repos/route.ts | 16 +-- .../web/src/features/search/gitApi.test.ts | 114 +++++++++--------- packages/web/src/features/search/gitApi.ts | 34 ++++-- packages/web/src/features/search/types.ts | 11 -- 8 files changed, 150 insertions(+), 133 deletions(-) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index eda8b60bc..da94f3bcb 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,8 +7,8 @@ import escapeStringRegexp from 'escape-string-regexp'; import { z } from 'zod'; import { getFileSource, listRepos, search, listCommits } from './client.js'; import { env, numberSchema } from './env.js'; -import { listReposRequestSchema } from './schemas.js'; -import { ListReposRequest, TextContent } from './types.js'; +import { listCommitsQueryParamsSchema, listReposRequestSchema } from './schemas.js'; +import { ListCommitsRequestSchema, ListReposRequest, TextContent } from './types.js'; import _dedent from "dedent"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -174,23 +174,9 @@ server.tool( server.tool( "list_commits", dedent`Get a list of commits for a given repository.`, - { - repoName: z.string().describe(`The name of the repository to search commits in.`), - 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`).optional(), - maxCount: z.number().int().positive().max(100).default(50).describe(`Maximum number of commits to return (default: 50).`), - }, - async ({ repoName, query, since, until, author, maxCount }) => { - const result = await listCommits({ - repository: repoName, - query, - since, - until, - author, - maxCount, - }); + listCommitsQueryParamsSchema.shape, + async (request: ListCommitsRequestSchema) => { + const result = await listCommits(request); return { content: [{ type: "text", text: JSON.stringify(result) }], @@ -202,8 +188,8 @@ server.tool( "list_repos", dedent`Lists repositories in the organization with optional filtering and pagination.`, listReposRequestSchema.shape, - async ({ query, page = 1, perPage = 30, sort = 'name', direction = 'asc' }: ListReposRequest) => { - const result = await listRepos({ query, page, perPage, sort, direction }); + async (request: ListReposRequest) => { + const result = await listRepos(request); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts index f1f97d10a..a8a9822a9 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -209,15 +209,45 @@ export const serviceErrorSchema = z.object({ message: z.string(), }); -export const listCommitsRequestSchema = 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(100).optional(), - page: z.number().int().positive().optional(), - perPage: z.number().int().positive().max(100).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`) + .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 listCommitsResponseSchema = z.array(z.object({ diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 4b020b050..3f8005449 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -11,7 +11,7 @@ import { fileSourceRequestSchema, symbolSchema, serviceErrorSchema, - listCommitsRequestSchema, + listCommitsQueryParamsSchema, listCommitsResponseSchema, } from "./schemas.js"; import { z } from "zod"; @@ -34,5 +34,5 @@ export type TextContent = { type: "text", text: string }; export type ServiceError = z.infer; -export type ListCommitsRequestSchema = z.infer; +export type ListCommitsRequestSchema = z.infer; export type ListCommitsResponse = z.infer; diff --git a/packages/web/src/app/api/(server)/commits/route.ts b/packages/web/src/app/api/(server)/commits/route.ts index fa9379aa9..6368235e2 100644 --- a/packages/web/src/app/api/(server)/commits/route.ts +++ b/packages/web/src/app/api/(server)/commits/route.ts @@ -1,30 +1,29 @@ -import { searchCommits } from "@/features/search/gitApi"; +import { listCommits } from "@/features/search/gitApi"; import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; import { z } from "zod"; -const querySchema = z.object({ - repository: z.string(), +const listCommitsQueryParamsSchema = z.object({ + repo: z.string(), query: z.string().optional(), since: z.string().optional(), until: z.string().optional(), author: z.string().optional(), + ref: z.string().optional(), page: z.coerce.number().int().positive().default(1), perPage: z.coerce.number().int().positive().max(100).default(50), }); export const GET = async (request: NextRequest): Promise => { - const parsed = querySchema.safeParse({ - repository: request.nextUrl.searchParams.get('repository') ?? undefined, - query: request.nextUrl.searchParams.get('query') ?? undefined, - since: request.nextUrl.searchParams.get('since') ?? undefined, - until: request.nextUrl.searchParams.get('until') ?? undefined, - author: request.nextUrl.searchParams.get('author') ?? undefined, - page: request.nextUrl.searchParams.get('page') ?? undefined, - perPage: request.nextUrl.searchParams.get('perPage') ?? undefined, - }); + const rawParams = Object.fromEntries( + Object.keys(listCommitsQueryParamsSchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined + ]) + ); + const parsed = listCommitsQueryParamsSchema.safeParse(rawParams); if (!parsed.success) { return serviceErrorResponse( @@ -35,7 +34,7 @@ export const GET = async (request: NextRequest): Promise => { const { page, perPage, ...searchParams } = parsed.data; const skip = (page - 1) * perPage; - const result = await searchCommits({ ...searchParams, maxCount: perPage, skip }); + const result = await listCommits({ ...searchParams, maxCount: perPage, skip }); if (isServiceError(result)) { return serviceErrorResponse(result); @@ -51,11 +50,12 @@ export const GET = async (request: NextRequest): Promise => { perPage, totalCount, extraParams: { - repository: searchParams.repository, + repo: searchParams.repo, ...(searchParams.query && { query: searchParams.query }), ...(searchParams.since && { since: searchParams.since }), ...(searchParams.until && { until: searchParams.until }), ...(searchParams.author && { author: searchParams.author }), + ...(searchParams.ref && { ref: searchParams.ref }), }, }); if (linkHeader) headers.set('Link', linkHeader); diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 7fe75549e..57d305dc1 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -7,7 +7,7 @@ import { isServiceError } from "@/lib/utils"; import { repositoryQuerySchema } from "@/lib/schemas"; import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; -const querySchema = z.object({ +const listReposQueryParamsSchema = z.object({ page: z.coerce.number().int().positive().default(1), perPage: z.coerce.number().int().positive().max(100).default(30), sort: z.enum(['name', 'pushed']).default('name'), @@ -16,13 +16,13 @@ const querySchema = z.object({ }); export const GET = async (request: NextRequest) => { - const parseResult = querySchema.safeParse({ - page: request.nextUrl.searchParams.get('page') ?? undefined, - perPage: request.nextUrl.searchParams.get('perPage') ?? undefined, - sort: request.nextUrl.searchParams.get('sort') ?? undefined, - direction: request.nextUrl.searchParams.get('direction') ?? undefined, - query: request.nextUrl.searchParams.get('query') ?? undefined, - }); + const rawParams = Object.fromEntries( + Object.keys(listReposQueryParamsSchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined + ]) + ); + const parseResult = listReposQueryParamsSchema.safeParse(rawParams); if (!parseResult.success) { return serviceErrorResponse(queryParamsSchemaValidationError(parseResult.error)); diff --git a/packages/web/src/features/search/gitApi.test.ts b/packages/web/src/features/search/gitApi.test.ts index ca5ac403e..d9c316bad 100644 --- a/packages/web/src/features/search/gitApi.test.ts +++ b/packages/web/src/features/search/gitApi.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { searchCommits } from './gitApi'; +import { listCommits } from './gitApi'; import * as dateUtils from './dateUtils'; // Mock dependencies @@ -88,8 +88,8 @@ describe('searchCommits', () => { it('should return error when repository is not found in database', async () => { mockFindFirst.mockResolvedValue(null); - const result = await searchCommits({ - repository: 'github.com/nonexistent/repo', + const result = await listCommits({ + repo: 'github.com/nonexistent/repo', }); expect(result).toMatchObject({ @@ -102,8 +102,8 @@ describe('searchCommits', () => { mockFindFirst.mockResolvedValue({ id: 456, name: 'github.com/test/repo' }); mockGitLog.mockResolvedValue({ all: [] }); - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', }); expect(mockFindFirst).toHaveBeenCalledWith({ @@ -121,8 +121,8 @@ describe('searchCommits', () => { 'Invalid date range: since must be before until' ); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', since: '2024-12-31', until: '2024-01-01', }); @@ -138,8 +138,8 @@ describe('searchCommits', () => { vi.spyOn(dateUtils, 'toGitDate').mockImplementation((date) => date); mockGitLog.mockResolvedValue({ all: [] }); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', since: '2024-01-01', until: '2024-12-31', }); @@ -154,8 +154,8 @@ describe('searchCommits', () => { toGitDateSpy.mockImplementation((date) => date); mockGitLog.mockResolvedValue({ all: [] }); - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', since: '30 days ago', until: 'yesterday', }); @@ -170,8 +170,8 @@ describe('searchCommits', () => { .mockReturnValueOnce('2024-12-31'); mockGitLog.mockResolvedValue({ all: [] }); - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', since: '30 days ago', until: 'yesterday', }); @@ -192,8 +192,8 @@ describe('searchCommits', () => { }); it('should set default maxCount', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', }); expect(mockGitLog).toHaveBeenCalledWith( @@ -204,8 +204,8 @@ describe('searchCommits', () => { }); it('should use custom maxCount', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', maxCount: 100, }); @@ -217,8 +217,8 @@ describe('searchCommits', () => { }); it('should add --since when since is provided', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', since: '30 days ago', }); @@ -230,8 +230,8 @@ describe('searchCommits', () => { }); it('should add --until when until is provided', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', until: 'yesterday', }); @@ -243,8 +243,8 @@ describe('searchCommits', () => { }); it('should add --author when author is provided', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', author: 'john@example.com', }); @@ -256,8 +256,8 @@ describe('searchCommits', () => { }); it('should add --grep and --regexp-ignore-case when query is provided', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', query: 'fix bug', }); @@ -270,8 +270,8 @@ describe('searchCommits', () => { }); it('should combine all options', async () => { - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', query: 'feature', since: '2024-01-01', until: '2024-12-31', @@ -315,8 +315,8 @@ describe('searchCommits', () => { mockGitLog.mockResolvedValue({ all: mockCommits }); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', }); expect(result).toEqual(mockCommits); @@ -325,8 +325,8 @@ describe('searchCommits', () => { it('should return empty array when no commits match', async () => { mockGitLog.mockResolvedValue({ all: [] }); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', query: 'nonexistent', }); @@ -338,8 +338,8 @@ describe('searchCommits', () => { it('should return error for "not a git repository"', async () => { mockGitLog.mockRejectedValue(new Error('not a git repository')); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', }); expect(result).toMatchObject({ @@ -351,8 +351,8 @@ describe('searchCommits', () => { it('should return error for "ambiguous argument"', async () => { mockGitLog.mockRejectedValue(new Error('ambiguous argument')); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', since: 'invalid-date', }); @@ -365,8 +365,8 @@ describe('searchCommits', () => { it('should return error for timeout', async () => { mockGitLog.mockRejectedValue(new Error('timeout exceeded')); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', }); expect(result).toMatchObject({ @@ -378,8 +378,8 @@ describe('searchCommits', () => { it('should return ServiceError for other Error instances', async () => { mockGitLog.mockRejectedValue(new Error('some other error')); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', }); expect(result).toMatchObject({ @@ -391,8 +391,8 @@ describe('searchCommits', () => { it('should return ServiceError for non-Error exceptions', async () => { mockGitLog.mockRejectedValue('string error'); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', }); expect(result).toMatchObject({ @@ -406,8 +406,8 @@ describe('searchCommits', () => { it('should set working directory using cwd', async () => { mockGitLog.mockResolvedValue({ all: [] }); - await searchCommits({ - repository: 'github.com/test/repo', + await listCommits({ + repo: 'github.com/test/repo', }); expect(mockCwd).toHaveBeenCalledWith('/mock/cache/dir/123'); @@ -417,8 +417,8 @@ describe('searchCommits', () => { mockFindFirst.mockResolvedValue({ id: 456, name: 'github.com/other/repo' }); mockGitLog.mockResolvedValue({ all: [] }); - await searchCommits({ - repository: 'github.com/other/repo', + await listCommits({ + repo: 'github.com/other/repo', }); expect(mockCwd).toHaveBeenCalledWith('/mock/cache/dir/456'); @@ -443,8 +443,8 @@ describe('searchCommits', () => { vi.spyOn(dateUtils, 'toGitDate').mockImplementation((date) => date); mockGitLog.mockResolvedValue({ all: mockCommits }); - const result = await searchCommits({ - repository: 'github.com/test/repo', + const result = await listCommits({ + repo: 'github.com/test/repo', query: 'authentication', since: '30 days ago', until: 'yesterday', @@ -466,8 +466,8 @@ describe('searchCommits', () => { it('should handle repository not found in database', async () => { mockFindFirst.mockResolvedValue(null); - const result = await searchCommits({ - repository: 'github.com/nonexistent/repo', + const result = await listCommits({ + repo: 'github.com/nonexistent/repo', query: 'feature', }); @@ -491,8 +491,8 @@ describe('searchCommits', () => { mockFindFirst.mockResolvedValue({ id: 456, name: 'github.com/owner/repo' }); mockGitLog.mockResolvedValue({ all: [] }); - const result = await searchCommits({ - repository: 'github.com/owner/repo', + const result = await listCommits({ + repo: 'github.com/owner/repo', }); expect(Array.isArray(result)).toBe(true); @@ -507,8 +507,8 @@ describe('searchCommits', () => { it('should return NOT_FOUND error when repository is not found', async () => { mockFindFirst.mockResolvedValue(null); - const result = await searchCommits({ - repository: 'github.com/nonexistent/repo', + const result = await listCommits({ + repo: 'github.com/nonexistent/repo', }); expect(result).toMatchObject({ @@ -521,8 +521,8 @@ describe('searchCommits', () => { mockFindFirst.mockResolvedValue({ id: 789, name: 'github.com/example/project' }); mockGitLog.mockResolvedValue({ all: [] }); - await searchCommits({ - repository: 'github.com/example/project', + await listCommits({ + repo: 'github.com/example/project', }); expect(mockCwd).toHaveBeenCalledWith('/mock/cache/dir/789'); @@ -546,8 +546,8 @@ describe('searchCommits', () => { vi.spyOn(dateUtils, 'toGitDate').mockImplementation((date) => date); mockGitLog.mockResolvedValue({ all: mockCommits }); - const result = await searchCommits({ - repository: 'github.com/test/repository', + const result = await listCommits({ + repo: 'github.com/test/repository', query: 'feature', since: '7 days ago', author: 'Developer', diff --git a/packages/web/src/features/search/gitApi.ts b/packages/web/src/features/search/gitApi.ts index ee6ddb7c5..f16ba29d5 100644 --- a/packages/web/src/features/search/gitApi.ts +++ b/packages/web/src/features/search/gitApi.ts @@ -4,7 +4,6 @@ import { withOptionalAuthV2 } from '@/withAuthV2'; import { getRepoPath } from '@sourcebot/shared'; import { simpleGit } from 'simple-git'; import { toGitDate, validateDateRange } from './dateUtils'; -import { SearchCommitsRequest } from './types'; export interface Commit { hash: string; @@ -21,32 +20,44 @@ export interface SearchCommitsResult { totalCount: number; } +type ListCommitsRequest = { + repo: string; + query?: string; + since?: string; + until?: string; + author?: string; + ref?: string; + maxCount?: number; + skip?: number; +} + /** - * Search commits in a repository using git log. + * List commits in a repository using git log. * * **Date Formats**: Supports both ISO 8601 dates and relative formats * (e.g., "30 days ago", "last week", "yesterday"). Git natively handles * these formats in the --since and --until flags. */ -export const searchCommits = async ({ - repository, +export const listCommits = async ({ + repo: repoName, query, since, until, author, + ref = 'HEAD', maxCount = 50, skip = 0, -}: SearchCommitsRequest): Promise => sew(() => +}: ListCommitsRequest): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma }) => { const repo = await prisma.repo.findFirst({ where: { - name: repository, + name: repoName, orgId: org.id, }, }); if (!repo) { - return notFound(`Repository "${repository}" not found.`); + return notFound(`Repository "${repoName}" not found.`); } const { path: repoPath } = getRepoPath(repo); @@ -65,6 +76,7 @@ export const searchCommits = async ({ try { const sharedOptions: Record = { + [ref]: null, ...(gitSince ? { '--since': gitSince } : {}), ...(gitUntil ? { '--until': gitUntil } : {}), ...(author ? { '--author': author } : {}), @@ -82,7 +94,7 @@ export const searchCommits = async ({ }); // Then, use rev-list to get the total count of commits - const countArgs = ['rev-list', '--count', 'HEAD']; + const countArgs = ['rev-list', '--count', ref]; for (const [key, value] of Object.entries(sharedOptions)) { countArgs.push(value !== null ? `${key}=${value}` : key); } @@ -110,7 +122,7 @@ export const searchCommits = async ({ if (errorMessage.includes('timeout')) { return unexpectedError( - `Git operation timed out after 30 seconds for repository ${repository}. ` + + `Git operation timed out after 30 seconds for repository ${repoName}. ` + `The repository may be too large or the git operation is taking too long.` ); } @@ -118,11 +130,11 @@ export const searchCommits = async ({ // Generic error fallback if (error instanceof Error) { throw new Error( - `Failed to search commits in repository ${repository}: ${error.message}` + `Failed to search commits in repository ${repoName}: ${error.message}` ); } else { throw new Error( - `Failed to search commits in repository ${repository}: ${errorMessage}` + `Failed to search commits in repository ${repoName}: ${errorMessage}` ); } } diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts index 901eeb19b..c90cfdd14 100644 --- a/packages/web/src/features/search/types.ts +++ b/packages/web/src/features/search/types.ts @@ -163,14 +163,3 @@ export const fileSourceResponseSchema = z.object({ webUrl: z.string().optional(), }); export type FileSourceResponse = z.infer; - -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(), - skip: z.number().int().nonnegative().optional(), -}); -export type SearchCommitsRequest = z.infer; From e7a9f37a197e7bbf790f16746072761eecbad614 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 21:28:52 -0800 Subject: [PATCH 06/16] wip --- packages/mcp/src/client.ts | 19 +++++++------------ packages/mcp/src/index.ts | 10 +++++----- packages/mcp/src/schemas.ts | 4 ++-- packages/mcp/src/types.ts | 8 +++----- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 05b69a463..a6e596bb9 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,14 +1,9 @@ import { env } from './env.js'; -import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema } from './schemas.js'; -import { FileSourceRequest, ListReposRequest, SearchRequest, ListCommitsRequestSchema } from './types.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 interface ListReposResult { - repos: z.infer; - totalCount: number; -} - const parseResponse = async ( response: Response, schema: T @@ -48,10 +43,10 @@ export const search = async (request: SearchRequest) => { return parseResponse(response, searchResponseSchema); } -export const listRepos = async (request: ListReposRequest = {}) => { +export const listRepos = async (queryParams: ListReposQueryParams = {}) => { const url = new URL(`${env.SOURCEBOT_HOST}/api/repos`); - for (const [key, value] of Object.entries(request)) { + for (const [key, value] of Object.entries(queryParams)) { if (value) { url.searchParams.set(key, value.toString()); } @@ -65,7 +60,7 @@ export const listRepos = async (request: ListReposRequest = {}) => { }, }); - const repos = await parseResponse(response, listRepositoriesResponseSchema); + const repos = await parseResponse(response, listReposResponseSchema); const totalCount = parseInt(response.headers.get('X-Total-Count') ?? '0', 10); return { repos, totalCount }; } @@ -83,9 +78,9 @@ export const getFileSource = async (request: FileSourceRequest) => { return parseResponse(response, fileSourceResponseSchema); } -export const listCommits = async (request: ListCommitsRequestSchema) => { +export const listCommits = async (queryParams: ListCommitsQueryParamsSchema) => { const url = new URL(`${env.SOURCEBOT_HOST}/api/commits`); - for (const [key, value] of Object.entries(request)) { + for (const [key, value] of Object.entries(queryParams)) { if (value) { url.searchParams.set(key, value.toString()); } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index da94f3bcb..f4f3a5b63 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,8 +7,8 @@ import escapeStringRegexp from 'escape-string-regexp'; import { z } from 'zod'; import { getFileSource, listRepos, search, listCommits } from './client.js'; import { env, numberSchema } from './env.js'; -import { listCommitsQueryParamsSchema, listReposRequestSchema } from './schemas.js'; -import { ListCommitsRequestSchema, ListReposRequest, TextContent } from './types.js'; +import { listCommitsQueryParamsSchema, listReposQueryParamsSchema } from './schemas.js'; +import { ListCommitsQueryParamsSchema, ListReposQueryParams, TextContent } from './types.js'; import _dedent from "dedent"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -175,7 +175,7 @@ server.tool( "list_commits", dedent`Get a list of commits for a given repository.`, listCommitsQueryParamsSchema.shape, - async (request: ListCommitsRequestSchema) => { + async (request: ListCommitsQueryParamsSchema) => { const result = await listCommits(request); return { @@ -187,8 +187,8 @@ server.tool( server.tool( "list_repos", dedent`Lists repositories in the organization with optional filtering and pagination.`, - listReposRequestSchema.shape, - async (request: ListReposRequest) => { + listReposQueryParamsSchema.shape, + async (request: ListReposQueryParams) => { const result = await listRepos(request); return { content: [{ type: "text", text: JSON.stringify(result) }] }; diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts index a8a9822a9..0436d66b4 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -156,9 +156,9 @@ export const repositoryQuerySchema = z.object({ indexedAt: 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 (case-insensitive)") diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 3f8005449..cd64cb085 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -2,8 +2,7 @@ // At some point, we should move these to a shared package... import { fileSourceResponseSchema, - listRepositoriesResponseSchema, - listReposRequestSchema, + listReposQueryParamsSchema, locationSchema, searchRequestSchema, searchResponseSchema, @@ -24,8 +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 ListReposRequest = z.input; +export type ListReposQueryParams = z.input; export type FileSourceRequest = z.infer; export type FileSourceResponse = z.infer; @@ -34,5 +32,5 @@ export type TextContent = { type: "text", text: string }; export type ServiceError = z.infer; -export type ListCommitsRequestSchema = z.infer; +export type ListCommitsQueryParamsSchema = z.infer; export type ListCommitsResponse = z.infer; From ee14525edaa53662b1632eb83818d9d515175594 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 26 Jan 2026 21:52:54 -0800 Subject: [PATCH 07/16] update file source api to be a GET request --- packages/mcp/src/client.ts | 13 ++++--- packages/mcp/src/index.ts | 16 +++------ packages/mcp/src/schemas.ts | 8 ++--- packages/web/src/app/api/(client)/client.ts | 17 ++++++---- .../web/src/app/api/(server)/source/route.ts | 34 ++++++++++++++----- packages/web/src/features/search/types.ts | 11 +++--- 6 files changed, 58 insertions(+), 41 deletions(-) diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index a6e596bb9..036f251b6 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -66,13 +66,18 @@ export const listRepos = async (queryParams: ListReposQueryParams = {}) => { } export const getFileSource = async (request: FileSourceRequest) => { - const response = await fetch(`${env.SOURCEBOT_HOST}/api/source`, { - method: 'POST', + 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) }); return parseResponse(response, fileSourceResponseSchema); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f4f3a5b63..4b40fb0f3 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,8 +7,8 @@ import escapeStringRegexp from 'escape-string-regexp'; import { z } from 'zod'; import { getFileSource, listRepos, search, listCommits } from './client.js'; import { env, numberSchema } from './env.js'; -import { listCommitsQueryParamsSchema, listReposQueryParamsSchema } from './schemas.js'; -import { ListCommitsQueryParamsSchema, ListReposQueryParams, TextContent } from './types.js'; +import { fileSourceRequestSchema, listCommitsQueryParamsSchema, listReposQueryParamsSchema } from './schemas.js'; +import { FileSourceRequest, ListCommitsQueryParamsSchema, ListReposQueryParams, TextContent } from './types.js'; import _dedent from "dedent"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -198,15 +198,9 @@ server.tool( server.tool( "read_file", dedent`Reads the source code for a given file.`, - { - fileName: z.string().describe("The file to fetch the source code for."), - repoName: z.string().describe("The name of the repository to fetch the source code for."), - }, - async ({ fileName, repoName }) => { - const response = await getFileSource({ - fileName, - repository: repoName, - }); + fileSourceRequestSchema.shape, + async (request: FileSourceRequest) => { + const response = await getFileSource(request); return { content: [{ type: "text", text: JSON.stringify(response) }] }; } diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts index 0436d66b4..ae5ac8141 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -191,11 +191,9 @@ export const listReposQueryParamsSchema = z.object({ }); export const fileSourceRequestSchema = z.object({ - fileName: z - .string() - .describe("The name of the file to fetch the source code for."), - 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("The git ref (branch, tag, or commit)."), }); export const fileSourceResponseSchema = z.object({ diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 2bf319935..59284adf1 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -38,13 +38,16 @@ export const search = async (body: SearchRequest): Promise => { - const result = await fetch("/api/source", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), +export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest): Promise => { + const url = new URL("/api/source", window.location.origin); + url.searchParams.set("repo", repository); + url.searchParams.set("path", fileName); + if (branch) { + url.searchParams.set("ref", branch); + } + + const result = await fetch(url, { + method: "GET", }).then(response => response.json()); return result as FileSourceResponse | ServiceError; diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 07c5b958b..4268e09b9 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -1,21 +1,39 @@ 'use server'; import { getFileSource } from "@/features/search/fileSourceApi"; -import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { fileSourceRequestSchema } from "@/features/search/types"; +import { z } from "zod"; + +const querySchema = z.object({ + repo: z.string(), + path: z.string(), + ref: z.string().optional(), +}); + +export const GET = async (request: NextRequest) => { + const rawParams = Object.fromEntries( + Object.keys(querySchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined + ]) + ); + const parsed = querySchema.safeParse(rawParams); -export const POST = async (request: NextRequest) => { - const body = await request.json(); - const parsed = await fileSourceRequestSchema.safeParseAsync(body); if (!parsed.success) { return serviceErrorResponse( - requestBodySchemaValidationError(parsed.error) + queryParamsSchemaValidationError(parsed.error) ); } - - const response = await getFileSource(parsed.data); + + const { repo, path, ref } = parsed.data; + const response = await getFileSource({ + fileName: path, + repository: repo, + branch: ref, + }); + if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts index c90cfdd14..b7b959694 100644 --- a/packages/web/src/features/search/types.ts +++ b/packages/web/src/features/search/types.ts @@ -144,12 +144,11 @@ export const streamedSearchResponseSchema = z.discriminatedUnion('type', [ ]); export type StreamedSearchResponse = z.infer; -export const fileSourceRequestSchema = z.object({ - fileName: z.string(), - repository: z.string(), - branch: z.string().optional(), -}); -export type FileSourceRequest = z.infer; +export interface FileSourceRequest { + fileName: string; + repository: string; + branch?: string; +} export const fileSourceResponseSchema = z.object({ source: z.string(), From 3a4323f7b3ec0bce5a7d52f8f81d39d9266ff0fa Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 27 Jan 2026 10:25:32 -0800 Subject: [PATCH 08/16] case insensitive author search --- packages/mcp/src/schemas.ts | 15 +++++++++++---- packages/web/src/features/search/gitApi.ts | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts index ae5ac8141..97b35c7a8 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -191,9 +191,16 @@ export const listReposQueryParamsSchema = z.object({ }); export const fileSourceRequestSchema = z.object({ - repo: z.string().describe("The repository name."), - path: z.string().describe("The path to the file."), - ref: z.string().optional().describe("The git ref (branch, tag, or commit)."), + 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({ @@ -225,7 +232,7 @@ export const listCommitsQueryParamsSchema = z.object({ .optional(), author: z .string() - .describe(`Filter commits by author name or email`) + .describe(`Filter commits by author name or email (case-insensitive).`) .optional(), ref: z .string() diff --git a/packages/web/src/features/search/gitApi.ts b/packages/web/src/features/search/gitApi.ts index f16ba29d5..38adaf3c9 100644 --- a/packages/web/src/features/search/gitApi.ts +++ b/packages/web/src/features/search/gitApi.ts @@ -79,7 +79,10 @@ export const listCommits = async ({ [ref]: null, ...(gitSince ? { '--since': gitSince } : {}), ...(gitUntil ? { '--until': gitUntil } : {}), - ...(author ? { '--author': author } : {}), + ...(author ? { + '--author': author, + '--regexp-ignore-case': null /// Case insensitive + } : {}), ...(query ? { '--grep': query, '--regexp-ignore-case': null /// Case insensitive From 41efac148530b019f84e23f4e7f40cc0cda5ddf0 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 27 Jan 2026 12:34:30 -0800 Subject: [PATCH 09/16] use paginated api for search suggestions --- .../searchBar/searchSuggestionsBox.tsx | 1 + .../searchBar/useSuggestionsData.ts | 12 ++++++++--- packages/web/src/app/api/(client)/client.ts | 21 +++++++++++-------- .../web/src/app/api/(server)/repos/route.ts | 11 +--------- packages/web/src/lib/schemas.ts | 11 ++++++++-- packages/web/src/lib/types.ts | 5 +++-- 6 files changed, 35 insertions(+), 26 deletions(-) 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/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 59284adf1..37216912a 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -1,7 +1,7 @@ 'use client'; import { ServiceError } from "@/lib/serviceError"; -import { GetVersionResponse, GetReposResponse } from "@/lib/types"; +import { GetVersionResponse, ListReposQueryParams, ListReposResponse } from "@/lib/types"; import { isServiceError } from "@/lib/utils"; import { SearchRequest, @@ -38,12 +38,10 @@ export const search = async (body: SearchRequest): Promise => { +export const getFileSource = async (queryParams: FileSourceRequest): Promise => { const url = new URL("/api/source", window.location.origin); - url.searchParams.set("repo", repository); - url.searchParams.set("path", fileName); - if (branch) { - url.searchParams.set("ref", branch); + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value.toString()); } const result = await fetch(url, { @@ -53,15 +51,20 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource return result as FileSourceResponse | ServiceError; } -export const getRepos = async (): Promise => { - const result = await fetch("/api/repos", { +export const listRepos = async (queryParams: ListReposQueryParams): Promise => { + const url = new URL("/api/repos", window.location.origin); + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value.toString()); + } + + const result = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json", }, }).then(response => response.json()); - return result as GetReposResponse | ServiceError; + return result as ListReposResponse | ServiceError; } export const getVersion = async (): Promise => { diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 57d305dc1..1ff67eb77 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -1,20 +1,11 @@ import { NextRequest } from "next/server"; -import { z } from "zod"; import { sew } from "@/actions"; import { withOptionalAuthV2 } from "@/withAuthV2"; import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; -import { repositoryQuerySchema } from "@/lib/schemas"; +import { listReposQueryParamsSchema, repositoryQuerySchema } from "@/lib/schemas"; import { buildLinkHeader, getBaseUrl } from "@/lib/pagination"; -const listReposQueryParamsSchema = z.object({ - page: z.coerce.number().int().positive().default(1), - perPage: z.coerce.number().int().positive().max(100).default(30), - sort: z.enum(['name', 'pushed']).default('name'), - direction: z.enum(['asc', 'desc']).default('asc'), - query: z.string().optional(), -}); - export const GET = async (request: NextRequest) => { const rawParams = Object.fromEntries( Object.keys(listReposQueryParamsSchema.shape).map(key => [ diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 1e927883e..7fb990cf2 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -1,7 +1,6 @@ import { checkIfOrgDomainExists } from "@/actions"; import { z } from "zod"; import { isServiceError } from "./utils"; -import { serviceErrorSchema } from "./serviceError"; import { CodeHostType } from "@sourcebot/db"; export const secretCreateRequestSchema = z.object({ @@ -72,4 +71,12 @@ export const getVersionResponseSchema = z.object({ version: z.string(), }); -export const getReposResponseSchema = z.union([repositoryQuerySchema.array(), serviceErrorSchema]); \ No newline at end of file +export const listReposQueryParamsSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(30), + sort: z.enum(['name', 'pushed']).default('name'), + direction: z.enum(['asc', 'desc']).default('asc'), + query: z.string().optional(), +}); + +export const listReposResponseSchema = repositoryQuerySchema.array(); \ No newline at end of file diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index cb6dc3c2f..20c58529f 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { getReposResponseSchema, getVersionResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas"; +import { listReposResponseSchema, getVersionResponseSchema, repositoryQuerySchema, searchContextQuerySchema, listReposQueryParamsSchema } from "./schemas"; import { tenancyModeSchema } from "@sourcebot/shared"; export type KeymapType = "default" | "vim"; @@ -29,4 +29,5 @@ export type NewsItem = { export type TenancyMode = z.infer; export type RepositoryQuery = z.infer; export type SearchContextQuery = z.infer; -export type GetReposResponse = z.infer; \ No newline at end of file +export type ListReposResponse = z.infer; +export type ListReposQueryParams = z.infer; \ No newline at end of file From 583fee24df867fc1f396b2c9760c4da937528c93 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 27 Jan 2026 15:21:28 -0800 Subject: [PATCH 10/16] rename webUrl to externalWebUrl for repos --- packages/mcp/src/index.ts | 28 +++++++++++++++++-- packages/mcp/src/schemas.ts | 13 +++++++-- packages/web/src/actions.ts | 16 +++++++++-- .../[...path]/components/codePreviewPanel.tsx | 8 +++--- .../[...path]/components/treePreviewPanel.tsx | 2 +- .../src/app/[domain]/browse/hooks/utils.ts | 4 +-- .../navigationMenu/progressIndicator.tsx | 4 +-- .../app/[domain]/components/pathHeader.tsx | 6 ++-- .../components/repositoryCarousel.tsx | 12 ++------ .../web/src/app/[domain]/repos/[id]/page.tsx | 6 ++-- .../repos/components/repoActionsDropdown.tsx | 2 +- .../search/components/filterPanel/index.tsx | 2 +- .../searchResultsPanel/fileMatchContainer.tsx | 2 +- .../web/src/app/api/(server)/commits/route.ts | 4 +-- .../web/src/app/api/(server)/repos/route.ts | 14 +++++++--- .../components/exploreMenu/referenceList.tsx | 2 +- .../referencedFileSourceListItem.tsx | 2 +- .../chatThread/referencedSourcesListView.tsx | 6 ++-- packages/web/src/features/chat/tools.ts | 2 +- .../web/src/features/search/fileSourceApi.ts | 24 ++++++++++++---- packages/web/src/features/search/types.ts | 11 ++++---- packages/web/src/lib/pagination.ts | 14 ++-------- packages/web/src/lib/schemas.ts | 4 +-- packages/web/src/lib/utils.ts | 22 +++++++-------- 24 files changed, 128 insertions(+), 82 deletions(-) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 4b40fb0f3..9698ab094 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -179,7 +179,9 @@ server.tool( const result = await listCommits(request); return { - content: [{ type: "text", text: JSON.stringify(result) }], + content: [{ + type: "text", text: JSON.stringify(result) + }], }; } ); @@ -191,7 +193,18 @@ server.tool( async (request: ListReposQueryParams) => { const result = await listRepos(request); - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + return { + content: [{ + type: "text", text: JSON.stringify({ + repos: result.repos.map((repo) => ({ + name: repo.repoName, + url: repo.webUrl, + pushedAt: repo.pushedAt, + })), + totalCount: result.totalCount, + }) + }] + }; } ); @@ -202,7 +215,16 @@ server.tool( async (request: FileSourceRequest) => { const response = await getFileSource(request); - return { content: [{ type: "text", text: JSON.stringify(response) }] }; + return { + 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 97b35c7a8..f67d11403 100644 --- a/packages/mcp/src/schemas.ts +++ b/packages/mcp/src/schemas.ts @@ -150,10 +150,11 @@ 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 listReposResponseSchema = repositoryQuerySchema.array(); @@ -206,6 +207,14 @@ export const fileSourceRequestSchema = z.object({ 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({ diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index e193c614f..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,14 +477,22 @@ 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, @@ -632,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 b932cd47e..5ca160133 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, }} branchDisplayName={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 8d6b335c0..83b3e80bd 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 912e18974..d35088e55 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; @@ -65,7 +65,7 @@ export const PathHeader = ({ name: repo.name, codeHostType: repo.codeHostType, displayName: repo.displayName, - webUrl: repo.webUrl, + externalWebUrl: repo.externalWebUrl, }); const { toast } = useToast(); @@ -204,7 +204,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]/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 && (