diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a20f55e..309ef90d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added `GET /api/diff` endpoint for retrieving structured diffs between two git refs ([#1063](https://github.com/sourcebot-dev/sourcebot/pull/1063)) + ## [4.16.3] - 2026-03-27 ### Added diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 11658cf27..451781f16 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -4,17 +4,7 @@ "title": "Sourcebot Public API", "version": "v4.16.3", "description": "OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing. Authentication is instance-dependent: API keys are the standard integration mechanism, OAuth bearer tokens are EE-only, and some instances may allow anonymous access." - }, - "security": [ - { - "bearerAuth": [] - }, - { - "sourcebotApiKey": [] - }, - {} - ], "tags": [ { "name": "Search", @@ -28,11 +18,24 @@ "name": "Files", "description": "File tree, file listing, and file content endpoints." }, + { + "name": "Git", + "description": "Git history and diff endpoints." + }, { "name": "Misc", "description": "Miscellaneous public API endpoints." } ], + "security": [ + { + "bearerToken": [] + }, + { + "apiKeyHeader": [] + }, + {} + ], "components": { "schemas": { "PublicSearchResponse": { @@ -638,6 +641,94 @@ "revisionName" ] }, + "PublicGetDiffResponse": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldPath": { + "type": "string", + "description": "The file path before the change. `/dev/null` for added files." + }, + "newPath": { + "type": "string", + "description": "The file path after the change. `/dev/null` for deleted files." + }, + "hunks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldRange": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "The 1-based line number where the range starts." + }, + "lines": { + "type": "integer", + "description": "The number of lines the range spans." + } + }, + "required": [ + "start", + "lines" + ], + "description": "The line range in the old file." + }, + "newRange": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "The 1-based line number where the range starts." + }, + "lines": { + "type": "integer", + "description": "The number of lines the range spans." + } + }, + "required": [ + "start", + "lines" + ], + "description": "The line range in the new file." + }, + "heading": { + "type": "string", + "description": "Optional context heading extracted from the @@ line, typically the enclosing function or class name." + }, + "body": { + "type": "string", + "description": "The diff content, with each line prefixed by a space (context), + (addition), or - (deletion)." + } + }, + "required": [ + "oldRange", + "newRange", + "body" + ] + }, + "description": "The list of changed regions within the file." + } + }, + "required": [ + "oldPath", + "newPath", + "hunks" + ] + }, + "description": "The list of changed files." + } + }, + "required": [ + "files" + ] + }, "PublicFileTreeNode": { "type": "object", "properties": { @@ -668,16 +759,16 @@ }, "parameters": {}, "securitySchemes": { - "bearerAuth": { + "bearerToken": { "type": "http", "scheme": "bearer", - "description": "Send either a Sourcebot API key (`sbk_...` or legacy `sourcebot-...`) or, on EE instances with OAuth enabled, an OAuth access token (`sboa_...`) in the Authorization header." + "description": "Send either a Sourcebot API key (`sbk_...`) or, on EE instances with OAuth enabled, an OAuth access token (`sboa_...`) in the Authorization header." }, - "sourcebotApiKey": { + "apiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-Sourcebot-Api-Key", - "description": "Send a Sourcebot API key (`sbk_...` or legacy `sourcebot-...`) in the X-Sourcebot-Api-Key header." + "description": "Send a Sourcebot API key (`sbk_...`) in the X-Sourcebot-Api-Key header." } } }, @@ -1129,6 +1220,89 @@ } } } + }, + "/api/diff": { + "get": { + "tags": [ + "Git" + ], + "summary": "Get diff between two commits", + "description": "Returns a structured diff between two git refs (branches, tags, or commit SHAs) using a two-dot comparison. See [git-diff](https://git-scm.com/docs/git-diff) for details.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The fully-qualified repository name." + }, + "required": true, + "description": "The fully-qualified repository name.", + "name": "repo", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The base git ref (branch, tag, or commit SHA) to diff from." + }, + "required": true, + "description": "The base git ref (branch, tag, or commit SHA) to diff from.", + "name": "base", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The head git ref (branch, tag, or commit SHA) to diff to." + }, + "required": true, + "description": "The head git ref (branch, tag, or commit SHA) to diff to.", + "name": "head", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Structured diff between the two refs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicGetDiffResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters or git ref.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Repository not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected diff failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } } } } diff --git a/packages/web/src/app/api/(server)/diff/route.ts b/packages/web/src/app/api/(server)/diff/route.ts new file mode 100644 index 000000000..452f30e5c --- /dev/null +++ b/packages/web/src/app/api/(server)/diff/route.ts @@ -0,0 +1,36 @@ +import { getDiff } from "@/features/git"; +import { apiHandler } from "@/lib/apiHandler"; +import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +const getDiffQueryParamsSchema = z.object({ + repo: z.string(), + base: z.string(), + head: z.string(), +}); + +export const GET = apiHandler(async (request: NextRequest): Promise => { + const rawParams = Object.fromEntries( + Object.keys(getDiffQueryParamsSchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined + ]) + ); + const parsed = getDiffQueryParamsSchema.safeParse(rawParams); + + if (!parsed.success) { + return serviceErrorResponse( + queryParamsSchemaValidationError(parsed.error) + ); + } + + const result = await getDiff(parsed.data); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}); diff --git a/packages/web/src/features/git/getDiffApi.ts b/packages/web/src/features/git/getDiffApi.ts new file mode 100644 index 000000000..6031724dd --- /dev/null +++ b/packages/web/src/features/git/getDiffApi.ts @@ -0,0 +1,100 @@ +import { sew } from '@/actions'; +import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError'; +import { withOptionalAuthV2 } from '@/withAuthV2'; +import { getRepoPath } from '@sourcebot/shared'; +import parseDiff from 'parse-diff'; +import { simpleGit } from 'simple-git'; +import { isGitRefValid } from './utils'; + +export interface HunkRange { + start: number; + lines: number; +} + +export interface DiffHunk { + oldRange: HunkRange; + newRange: HunkRange; + heading?: string; + body: string; +} + +export interface FileDiff { + oldPath: string; + newPath: string; + hunks: DiffHunk[]; +} + +export interface GetDiffResult { + files: FileDiff[]; +} + +type GetDiffRequest = { + repo: string; + base: string; + head: string; +} + +export const getDiff = async ({ + repo: repoName, + base, + head, +}: GetDiffRequest): Promise => sew(() => + withOptionalAuthV2(async ({ org, prisma }) => { + if (!isGitRefValid(base)) { + return invalidGitRef(base); + } + + if (!isGitRefValid(head)) { + return invalidGitRef(head); + } + + const repo = await prisma.repo.findFirst({ + where: { + name: repoName, + orgId: org.id, + }, + }); + + if (!repo) { + return notFound(`Repository "${repoName}" not found.`); + } + + const { path: repoPath } = getRepoPath(repo); + const git = simpleGit().cwd(repoPath); + + try { + const rawDiff = await git.raw(['diff', base, head]); + const files = parseDiff(rawDiff); + + const nodes: FileDiff[] = files.map((file) => ({ + oldPath: file.from ?? '/dev/null', + newPath: file.to ?? '/dev/null', + hunks: file.chunks.map((chunk) => { + // chunk.content is the full @@ header line, e.g.: + // "@@ -7,6 +7,8 @@ some heading text" + // The heading is the optional text after the second @@. + const headingMatch = chunk.content.match(/^@@ .+ @@ (.+)$/); + const heading = headingMatch ? headingMatch[1].trim() : undefined; + + return { + oldRange: { start: chunk.oldStart, lines: chunk.oldLines }, + newRange: { start: chunk.newStart, lines: chunk.newLines }, + heading, + body: chunk.changes.map((change) => change.content).join('\n'), + }; + }), + })); + + return { + files: nodes, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('unknown revision') || message.includes('bad revision')) { + return invalidGitRef(`${base}..${head}`); + } + + return unexpectedError(`Failed to compute diff for ${repoName}: ${message}`); + } + })); diff --git a/packages/web/src/features/git/index.ts b/packages/web/src/features/git/index.ts index cfb4c39c4..4adb81966 100644 --- a/packages/web/src/features/git/index.ts +++ b/packages/web/src/features/git/index.ts @@ -1,3 +1,4 @@ +export * from './getDiffApi'; export * from './getFilesApi'; export * from './getFolderContentsApi'; export * from './getTreeApi'; diff --git a/packages/web/src/features/git/schemas.ts b/packages/web/src/features/git/schemas.ts index 794ce148b..91ee3a9b8 100644 --- a/packages/web/src/features/git/schemas.ts +++ b/packages/web/src/features/git/schemas.ts @@ -36,3 +36,31 @@ export const fileSourceResponseSchema = z.object({ webUrl: z.string(), externalWebUrl: z.string().optional(), }); + +export const getDiffRequestSchema = z.object({ + repo: z.string().describe('The fully-qualified repository name.'), + base: z.string().describe('The base git ref (branch, tag, or commit SHA) to diff from.'), + head: z.string().describe('The head git ref (branch, tag, or commit SHA) to diff to.'), +}); + +const hunkRangeSchema = z.object({ + start: z.number().int().describe('The 1-based line number where the range starts.'), + lines: z.number().int().describe('The number of lines the range spans.'), +}); + +const diffHunkSchema = z.object({ + oldRange: hunkRangeSchema.describe('The line range in the old file.'), + newRange: hunkRangeSchema.describe('The line range in the new file.'), + heading: z.string().optional().describe('Optional context heading extracted from the @@ line, typically the enclosing function or class name.'), + body: z.string().describe('The diff content, with each line prefixed by a space (context), + (addition), or - (deletion).'), +}); + +const fileDiffSchema = z.object({ + oldPath: z.string().describe('The file path before the change. `/dev/null` for added files.'), + newPath: z.string().describe('The file path after the change. `/dev/null` for deleted files.'), + hunks: z.array(diffHunkSchema).describe('The list of changed regions within the file.'), +}); + +export const getDiffResponseSchema = z.object({ + files: z.array(fileDiffSchema).describe('The list of changed files.'), +}); diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index 56d7aba52..1f05f89ea 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -4,6 +4,8 @@ import type { ComponentsObject, SchemaObject, SecuritySchemeObject } from 'opena import { publicFileSourceRequestSchema, publicFileSourceResponseSchema, + publicGetDiffRequestSchema, + publicGetDiffResponseSchema, publicGetFilesRequestSchema, publicGetFilesResponseSchema, publicGetTreeRequestSchema, @@ -19,6 +21,7 @@ import { const searchTag = { name: 'Search', description: 'Code search endpoints.' }; const reposTag = { name: 'Repositories', description: 'Repository listing and metadata endpoints.' }; const filesTag = { name: 'Files', description: 'File tree, file listing, and file content endpoints.' }; +const gitTag = { name: 'Git', description: 'Git history and diff endpoints.' }; const miscTag = { name: 'Misc', description: 'Miscellaneous public API endpoints.' }; const publicFileTreeNodeSchema: SchemaObject = { @@ -45,17 +48,22 @@ const publicGetTreeResponseSchema: SchemaObject = { additionalProperties: false, }; -const securitySchemes: Record = { - bearerAuth: { +const securitySchemeNames = { + bearerToken: 'bearerToken', + apiKeyHeader: 'apiKeyHeader', +} as const; + +const securitySchemes: Record = { + [securitySchemeNames.bearerToken]: { type: 'http', scheme: 'bearer', - description: 'Send either a Sourcebot API key (`sbk_...` or legacy `sourcebot-...`) or, on EE instances with OAuth enabled, an OAuth access token (`sboa_...`) in the Authorization header.', + description: 'Send either a Sourcebot API key (`sbk_...`) or, on EE instances with OAuth enabled, an OAuth access token (`sboa_...`) in the Authorization header.', }, - sourcebotApiKey: { + [securitySchemeNames.apiKeyHeader]: { type: 'apiKey', in: 'header', name: 'X-Sourcebot-Api-Key', - description: 'Send a Sourcebot API key (`sbk_...` or legacy `sourcebot-...`) in the X-Sourcebot-Api-Key header.', + description: 'Send a Sourcebot API key (`sbk_...`) in the X-Sourcebot-Api-Key header.', }, }; @@ -241,6 +249,27 @@ export function createPublicOpenApiDocument(version: string) { }, }); + registry.registerPath({ + method: 'get', + path: '/api/diff', + operationId: 'getDiff', + tags: [gitTag.name], + summary: 'Get diff between two commits', + description: 'Returns a structured diff between two git refs (branches, tags, or commit SHAs) using a two-dot comparison. See [git-diff](https://git-scm.com/docs/git-diff) for details.', + request: { + query: publicGetDiffRequestSchema, + }, + responses: { + 200: { + description: 'Structured diff between the two refs.', + content: jsonContent(publicGetDiffResponseSchema), + }, + 400: errorJson('Invalid query parameters or git ref.'), + 404: errorJson('Repository not found.'), + 500: errorJson('Unexpected diff failure.'), + }, + }); + const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ @@ -250,12 +279,12 @@ export function createPublicOpenApiDocument(version: string) { version, description: 'OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing. Authentication is instance-dependent: API keys are the standard integration mechanism, OAuth bearer tokens are EE-only, and some instances may allow anonymous access.', }, + tags: [searchTag, reposTag, filesTag, gitTag, miscTag], security: [ - { bearerAuth: [] }, - { sourcebotApiKey: [] }, + { [securitySchemeNames.bearerToken]: [] }, + { [securitySchemeNames.apiKeyHeader]: [] }, {}, ], - tags: [searchTag, reposTag, filesTag, miscTag], }); const components: ComponentsObject = document.components ?? {}; diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index 632ead9f4..b360195fa 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -4,6 +4,8 @@ import z from 'zod'; import { fileSourceRequestSchema, fileSourceResponseSchema, + getDiffRequestSchema, + getDiffResponseSchema, getFilesRequestSchema, getFilesResponseSchema, getTreeRequestSchema, @@ -69,6 +71,9 @@ export const publicListReposQuerySchema = z.object({ export const publicListReposResponseSchema = z.array(publicRepositorySchema).openapi('PublicListReposResponse'); +export const publicGetDiffRequestSchema = getDiffRequestSchema.openapi('PublicGetDiffRequest'); +export const publicGetDiffResponseSchema = getDiffResponseSchema.openapi('PublicGetDiffResponse'); + export const publicStreamSearchSseSchema = z.string().openapi('PublicStreamSearchSse', { description: 'Server-sent event stream. Each data frame contains one JSON object representing either a chunk update, a final summary, or an error.', example: 'data: {"type":"chunk","stats":{"actualMatchCount":1}}\n\n',