Skip to content

Commit 333f12a

Browse files
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>
1 parent 2fa86ff commit 333f12a

File tree

7 files changed

+369
-1
lines changed

7 files changed

+369
-1
lines changed

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"name": "Files",
1919
"description": "File tree, file listing, and file content endpoints."
2020
},
21+
{
22+
"name": "Git",
23+
"description": "Git history and diff endpoints."
24+
},
2125
{
2226
"name": "Misc",
2327
"description": "Miscellaneous public API endpoints."
@@ -629,6 +633,94 @@
629633
"revisionName"
630634
]
631635
},
636+
"PublicGetDiffResponse": {
637+
"type": "object",
638+
"properties": {
639+
"files": {
640+
"type": "array",
641+
"items": {
642+
"type": "object",
643+
"properties": {
644+
"oldPath": {
645+
"type": "string",
646+
"description": "The file path before the change. `/dev/null` for added files."
647+
},
648+
"newPath": {
649+
"type": "string",
650+
"description": "The file path after the change. `/dev/null` for deleted files."
651+
},
652+
"hunks": {
653+
"type": "array",
654+
"items": {
655+
"type": "object",
656+
"properties": {
657+
"oldRange": {
658+
"type": "object",
659+
"properties": {
660+
"start": {
661+
"type": "integer",
662+
"description": "The 1-based line number where the range starts."
663+
},
664+
"lines": {
665+
"type": "integer",
666+
"description": "The number of lines the range spans."
667+
}
668+
},
669+
"required": [
670+
"start",
671+
"lines"
672+
],
673+
"description": "The line range in the old file."
674+
},
675+
"newRange": {
676+
"type": "object",
677+
"properties": {
678+
"start": {
679+
"type": "integer",
680+
"description": "The 1-based line number where the range starts."
681+
},
682+
"lines": {
683+
"type": "integer",
684+
"description": "The number of lines the range spans."
685+
}
686+
},
687+
"required": [
688+
"start",
689+
"lines"
690+
],
691+
"description": "The line range in the new file."
692+
},
693+
"heading": {
694+
"type": "string",
695+
"description": "Optional context heading extracted from the @@ line, typically the enclosing function or class name."
696+
},
697+
"body": {
698+
"type": "string",
699+
"description": "The diff content, with each line prefixed by a space (context), + (addition), or - (deletion)."
700+
}
701+
},
702+
"required": [
703+
"oldRange",
704+
"newRange",
705+
"body"
706+
]
707+
},
708+
"description": "The list of changed regions within the file."
709+
}
710+
},
711+
"required": [
712+
"oldPath",
713+
"newPath",
714+
"hunks"
715+
]
716+
},
717+
"description": "The list of changed files."
718+
}
719+
},
720+
"required": [
721+
"files"
722+
]
723+
},
632724
"PublicFileTreeNode": {
633725
"type": "object",
634726
"properties": {
@@ -1100,6 +1192,89 @@
11001192
}
11011193
}
11021194
}
1195+
},
1196+
"/api/diff": {
1197+
"get": {
1198+
"tags": [
1199+
"Git"
1200+
],
1201+
"summary": "Get diff between two commits",
1202+
"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.",
1203+
"parameters": [
1204+
{
1205+
"schema": {
1206+
"type": "string",
1207+
"description": "The fully-qualified repository name."
1208+
},
1209+
"required": true,
1210+
"description": "The fully-qualified repository name.",
1211+
"name": "repo",
1212+
"in": "query"
1213+
},
1214+
{
1215+
"schema": {
1216+
"type": "string",
1217+
"description": "The base git ref (branch, tag, or commit SHA) to diff from."
1218+
},
1219+
"required": true,
1220+
"description": "The base git ref (branch, tag, or commit SHA) to diff from.",
1221+
"name": "base",
1222+
"in": "query"
1223+
},
1224+
{
1225+
"schema": {
1226+
"type": "string",
1227+
"description": "The head git ref (branch, tag, or commit SHA) to diff to."
1228+
},
1229+
"required": true,
1230+
"description": "The head git ref (branch, tag, or commit SHA) to diff to.",
1231+
"name": "head",
1232+
"in": "query"
1233+
}
1234+
],
1235+
"responses": {
1236+
"200": {
1237+
"description": "Structured diff between the two refs.",
1238+
"content": {
1239+
"application/json": {
1240+
"schema": {
1241+
"$ref": "#/components/schemas/PublicGetDiffResponse"
1242+
}
1243+
}
1244+
}
1245+
},
1246+
"400": {
1247+
"description": "Invalid query parameters or git ref.",
1248+
"content": {
1249+
"application/json": {
1250+
"schema": {
1251+
"$ref": "#/components/schemas/PublicApiServiceError"
1252+
}
1253+
}
1254+
}
1255+
},
1256+
"404": {
1257+
"description": "Repository not found.",
1258+
"content": {
1259+
"application/json": {
1260+
"schema": {
1261+
"$ref": "#/components/schemas/PublicApiServiceError"
1262+
}
1263+
}
1264+
}
1265+
},
1266+
"500": {
1267+
"description": "Unexpected diff failure.",
1268+
"content": {
1269+
"application/json": {
1270+
"schema": {
1271+
"$ref": "#/components/schemas/PublicApiServiceError"
1272+
}
1273+
}
1274+
}
1275+
}
1276+
}
1277+
}
11031278
}
11041279
}
11051280
}
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+
}));

packages/web/src/features/git/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './getDiffApi';
12
export * from './getFilesApi';
23
export * from './getFolderContentsApi';
34
export * from './getTreeApi';

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,31 @@ export const fileSourceResponseSchema = z.object({
3636
webUrl: z.string(),
3737
externalWebUrl: z.string().optional(),
3838
});
39+
40+
export const getDiffRequestSchema = z.object({
41+
repo: z.string().describe('The fully-qualified repository name.'),
42+
base: z.string().describe('The base git ref (branch, tag, or commit SHA) to diff from.'),
43+
head: z.string().describe('The head git ref (branch, tag, or commit SHA) to diff to.'),
44+
});
45+
46+
const hunkRangeSchema = z.object({
47+
start: z.number().int().describe('The 1-based line number where the range starts.'),
48+
lines: z.number().int().describe('The number of lines the range spans.'),
49+
});
50+
51+
const diffHunkSchema = z.object({
52+
oldRange: hunkRangeSchema.describe('The line range in the old file.'),
53+
newRange: hunkRangeSchema.describe('The line range in the new file.'),
54+
heading: z.string().optional().describe('Optional context heading extracted from the @@ line, typically the enclosing function or class name.'),
55+
body: z.string().describe('The diff content, with each line prefixed by a space (context), + (addition), or - (deletion).'),
56+
});
57+
58+
const fileDiffSchema = z.object({
59+
oldPath: z.string().describe('The file path before the change. `/dev/null` for added files.'),
60+
newPath: z.string().describe('The file path after the change. `/dev/null` for deleted files.'),
61+
hunks: z.array(diffHunkSchema).describe('The list of changed regions within the file.'),
62+
});
63+
64+
export const getDiffResponseSchema = z.object({
65+
files: z.array(fileDiffSchema).describe('The list of changed files.'),
66+
});

0 commit comments

Comments
 (0)