Skip to content

Commit f8e21d6

Browse files
feat(web): add GET /api/diff endpoint (#1063)
* feat(web): add GET /api/diff endpoint Adds a new public API endpoint for retrieving structured diffs between two git refs. Includes OpenAPI documentation with descriptions for all request/response fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update CHANGELOG for #1063 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * s * nit * feedback --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f0e521f commit f8e21d6

File tree

8 files changed

+398
-22
lines changed

8 files changed

+398
-22
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
- Added `GET /api/diff` endpoint for retrieving structured diffs between two git refs ([#1063](https://github.com/sourcebot-dev/sourcebot/pull/1063))
12+
1013
## [4.16.3] - 2026-03-27
1114

1215
### Added

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 188 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,7 @@
44
"title": "Sourcebot Public API",
55
"version": "v4.16.3",
66
"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."
7-
87
},
9-
"security": [
10-
{
11-
"bearerAuth": []
12-
},
13-
{
14-
"sourcebotApiKey": []
15-
},
16-
{}
17-
],
188
"tags": [
199
{
2010
"name": "Search",
@@ -28,11 +18,24 @@
2818
"name": "Files",
2919
"description": "File tree, file listing, and file content endpoints."
3020
},
21+
{
22+
"name": "Git",
23+
"description": "Git history and diff endpoints."
24+
},
3125
{
3226
"name": "Misc",
3327
"description": "Miscellaneous public API endpoints."
3428
}
3529
],
30+
"security": [
31+
{
32+
"bearerToken": []
33+
},
34+
{
35+
"apiKeyHeader": []
36+
},
37+
{}
38+
],
3639
"components": {
3740
"schemas": {
3841
"PublicSearchResponse": {
@@ -638,6 +641,94 @@
638641
"revisionName"
639642
]
640643
},
644+
"PublicGetDiffResponse": {
645+
"type": "object",
646+
"properties": {
647+
"files": {
648+
"type": "array",
649+
"items": {
650+
"type": "object",
651+
"properties": {
652+
"oldPath": {
653+
"type": "string",
654+
"description": "The file path before the change. `/dev/null` for added files."
655+
},
656+
"newPath": {
657+
"type": "string",
658+
"description": "The file path after the change. `/dev/null` for deleted files."
659+
},
660+
"hunks": {
661+
"type": "array",
662+
"items": {
663+
"type": "object",
664+
"properties": {
665+
"oldRange": {
666+
"type": "object",
667+
"properties": {
668+
"start": {
669+
"type": "integer",
670+
"description": "The 1-based line number where the range starts."
671+
},
672+
"lines": {
673+
"type": "integer",
674+
"description": "The number of lines the range spans."
675+
}
676+
},
677+
"required": [
678+
"start",
679+
"lines"
680+
],
681+
"description": "The line range in the old file."
682+
},
683+
"newRange": {
684+
"type": "object",
685+
"properties": {
686+
"start": {
687+
"type": "integer",
688+
"description": "The 1-based line number where the range starts."
689+
},
690+
"lines": {
691+
"type": "integer",
692+
"description": "The number of lines the range spans."
693+
}
694+
},
695+
"required": [
696+
"start",
697+
"lines"
698+
],
699+
"description": "The line range in the new file."
700+
},
701+
"heading": {
702+
"type": "string",
703+
"description": "Optional context heading extracted from the @@ line, typically the enclosing function or class name."
704+
},
705+
"body": {
706+
"type": "string",
707+
"description": "The diff content, with each line prefixed by a space (context), + (addition), or - (deletion)."
708+
}
709+
},
710+
"required": [
711+
"oldRange",
712+
"newRange",
713+
"body"
714+
]
715+
},
716+
"description": "The list of changed regions within the file."
717+
}
718+
},
719+
"required": [
720+
"oldPath",
721+
"newPath",
722+
"hunks"
723+
]
724+
},
725+
"description": "The list of changed files."
726+
}
727+
},
728+
"required": [
729+
"files"
730+
]
731+
},
641732
"PublicFileTreeNode": {
642733
"type": "object",
643734
"properties": {
@@ -668,16 +759,16 @@
668759
},
669760
"parameters": {},
670761
"securitySchemes": {
671-
"bearerAuth": {
762+
"bearerToken": {
672763
"type": "http",
673764
"scheme": "bearer",
674-
"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."
765+
"description": "Send either a Sourcebot API key (`sbk_...`) or, on EE instances with OAuth enabled, an OAuth access token (`sboa_...`) in the Authorization header."
675766
},
676-
"sourcebotApiKey": {
767+
"apiKeyHeader": {
677768
"type": "apiKey",
678769
"in": "header",
679770
"name": "X-Sourcebot-Api-Key",
680-
"description": "Send a Sourcebot API key (`sbk_...` or legacy `sourcebot-...`) in the X-Sourcebot-Api-Key header."
771+
"description": "Send a Sourcebot API key (`sbk_...`) in the X-Sourcebot-Api-Key header."
681772
}
682773
}
683774
},
@@ -1129,6 +1220,89 @@
11291220
}
11301221
}
11311222
}
1223+
},
1224+
"/api/diff": {
1225+
"get": {
1226+
"tags": [
1227+
"Git"
1228+
],
1229+
"summary": "Get diff between two commits",
1230+
"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.",
1231+
"parameters": [
1232+
{
1233+
"schema": {
1234+
"type": "string",
1235+
"description": "The fully-qualified repository name."
1236+
},
1237+
"required": true,
1238+
"description": "The fully-qualified repository name.",
1239+
"name": "repo",
1240+
"in": "query"
1241+
},
1242+
{
1243+
"schema": {
1244+
"type": "string",
1245+
"description": "The base git ref (branch, tag, or commit SHA) to diff from."
1246+
},
1247+
"required": true,
1248+
"description": "The base git ref (branch, tag, or commit SHA) to diff from.",
1249+
"name": "base",
1250+
"in": "query"
1251+
},
1252+
{
1253+
"schema": {
1254+
"type": "string",
1255+
"description": "The head git ref (branch, tag, or commit SHA) to diff to."
1256+
},
1257+
"required": true,
1258+
"description": "The head git ref (branch, tag, or commit SHA) to diff to.",
1259+
"name": "head",
1260+
"in": "query"
1261+
}
1262+
],
1263+
"responses": {
1264+
"200": {
1265+
"description": "Structured diff between the two refs.",
1266+
"content": {
1267+
"application/json": {
1268+
"schema": {
1269+
"$ref": "#/components/schemas/PublicGetDiffResponse"
1270+
}
1271+
}
1272+
}
1273+
},
1274+
"400": {
1275+
"description": "Invalid query parameters or git ref.",
1276+
"content": {
1277+
"application/json": {
1278+
"schema": {
1279+
"$ref": "#/components/schemas/PublicApiServiceError"
1280+
}
1281+
}
1282+
}
1283+
},
1284+
"404": {
1285+
"description": "Repository not found.",
1286+
"content": {
1287+
"application/json": {
1288+
"schema": {
1289+
"$ref": "#/components/schemas/PublicApiServiceError"
1290+
}
1291+
}
1292+
}
1293+
},
1294+
"500": {
1295+
"description": "Unexpected diff failure.",
1296+
"content": {
1297+
"application/json": {
1298+
"schema": {
1299+
"$ref": "#/components/schemas/PublicApiServiceError"
1300+
}
1301+
}
1302+
}
1303+
}
1304+
}
1305+
}
11321306
}
11331307
}
11341308
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { getDiff } from "@/features/git";
2+
import { apiHandler } from "@/lib/apiHandler";
3+
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
4+
import { isServiceError } from "@/lib/utils";
5+
import { NextRequest } from "next/server";
6+
import { z } from "zod";
7+
8+
const getDiffQueryParamsSchema = z.object({
9+
repo: z.string(),
10+
base: z.string(),
11+
head: z.string(),
12+
});
13+
14+
export const GET = apiHandler(async (request: NextRequest): Promise<Response> => {
15+
const rawParams = Object.fromEntries(
16+
Object.keys(getDiffQueryParamsSchema.shape).map(key => [
17+
key,
18+
request.nextUrl.searchParams.get(key) ?? undefined
19+
])
20+
);
21+
const parsed = getDiffQueryParamsSchema.safeParse(rawParams);
22+
23+
if (!parsed.success) {
24+
return serviceErrorResponse(
25+
queryParamsSchemaValidationError(parsed.error)
26+
);
27+
}
28+
29+
const result = await getDiff(parsed.data);
30+
31+
if (isServiceError(result)) {
32+
return serviceErrorResponse(result);
33+
}
34+
35+
return Response.json(result);
36+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { sew } from '@/actions';
2+
import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError';
3+
import { withOptionalAuthV2 } from '@/withAuthV2';
4+
import { getRepoPath } from '@sourcebot/shared';
5+
import parseDiff from 'parse-diff';
6+
import { simpleGit } from 'simple-git';
7+
import { isGitRefValid } from './utils';
8+
9+
export interface HunkRange {
10+
start: number;
11+
lines: number;
12+
}
13+
14+
export interface DiffHunk {
15+
oldRange: HunkRange;
16+
newRange: HunkRange;
17+
heading?: string;
18+
body: string;
19+
}
20+
21+
export interface FileDiff {
22+
oldPath: string;
23+
newPath: string;
24+
hunks: DiffHunk[];
25+
}
26+
27+
export interface GetDiffResult {
28+
files: FileDiff[];
29+
}
30+
31+
type GetDiffRequest = {
32+
repo: string;
33+
base: string;
34+
head: string;
35+
}
36+
37+
export const getDiff = async ({
38+
repo: repoName,
39+
base,
40+
head,
41+
}: GetDiffRequest): Promise<GetDiffResult | ServiceError> => sew(() =>
42+
withOptionalAuthV2(async ({ org, prisma }) => {
43+
if (!isGitRefValid(base)) {
44+
return invalidGitRef(base);
45+
}
46+
47+
if (!isGitRefValid(head)) {
48+
return invalidGitRef(head);
49+
}
50+
51+
const repo = await prisma.repo.findFirst({
52+
where: {
53+
name: repoName,
54+
orgId: org.id,
55+
},
56+
});
57+
58+
if (!repo) {
59+
return notFound(`Repository "${repoName}" not found.`);
60+
}
61+
62+
const { path: repoPath } = getRepoPath(repo);
63+
const git = simpleGit().cwd(repoPath);
64+
65+
try {
66+
const rawDiff = await git.raw(['diff', base, head]);
67+
const files = parseDiff(rawDiff);
68+
69+
const nodes: FileDiff[] = files.map((file) => ({
70+
oldPath: file.from ?? '/dev/null',
71+
newPath: file.to ?? '/dev/null',
72+
hunks: file.chunks.map((chunk) => {
73+
// chunk.content is the full @@ header line, e.g.:
74+
// "@@ -7,6 +7,8 @@ some heading text"
75+
// The heading is the optional text after the second @@.
76+
const headingMatch = chunk.content.match(/^@@ .+ @@ (.+)$/);
77+
const heading = headingMatch ? headingMatch[1].trim() : undefined;
78+
79+
return {
80+
oldRange: { start: chunk.oldStart, lines: chunk.oldLines },
81+
newRange: { start: chunk.newStart, lines: chunk.newLines },
82+
heading,
83+
body: chunk.changes.map((change) => change.content).join('\n'),
84+
};
85+
}),
86+
}));
87+
88+
return {
89+
files: nodes,
90+
};
91+
} catch (error: unknown) {
92+
const message = error instanceof Error ? error.message : String(error);
93+
94+
if (message.includes('unknown revision') || message.includes('bad revision')) {
95+
return invalidGitRef(`${base}..${head}`);
96+
}
97+
98+
return unexpectedError(`Failed to compute diff for ${repoName}: ${message}`);
99+
}
100+
}));

0 commit comments

Comments
 (0)