Skip to content

Commit 46e696c

Browse files
feat(web): add GET /api/commit endpoint and improve git API response formats (#1077)
* feat: add GET /api/commit endpoint and improve git API response formats Adds a new public API endpoint for retrieving details about a single commit including parent SHAs. Also improves existing git API responses by using camelCase field names and nullable types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add CHANGELOG entries for #1077 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * nits * fix: update tests to mock simple-git snake_case output separately from camelCase expected results Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * revert changes to deprecated mcp package --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05bb0c7 commit 46e696c

File tree

13 files changed

+384
-29
lines changed

13 files changed

+384
-29
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
- Added `GET /api/commit` endpoint for retrieving details about a single commit, including parent commit SHAs [#1077](https://github.com/sourcebot-dev/sourcebot/pull/1077)
12+
1013
### Changed
1114
- Replaced placeholder avatars with deterministic minidenticon-based avatars generated from email addresses [#1072](https://github.com/sourcebot-dev/sourcebot/pull/1072)
15+
- Changed `author_name` and `author_email` fields to `authorName` and `authorEmail` in `GET /api/commits` response [#1077](https://github.com/sourcebot-dev/sourcebot/pull/1077)
16+
- Changed `oldPath` and `newPath` in `GET /api/diff` response from `"/dev/null"` to `null` for added/deleted files [#1077](https://github.com/sourcebot-dev/sourcebot/pull/1077)
1217

1318
## [4.16.4] - 2026-04-01
1419

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

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -619,11 +619,13 @@
619619
"properties": {
620620
"oldPath": {
621621
"type": "string",
622-
"description": "The file path before the change. `/dev/null` for added files."
622+
"nullable": true,
623+
"description": "The file path before the change. `null` for added files."
623624
},
624625
"newPath": {
625626
"type": "string",
626-
"description": "The file path after the change. `/dev/null` for deleted files."
627+
"nullable": true,
628+
"description": "The file path after the change. `null` for deleted files."
627629
},
628630
"hunks": {
629631
"type": "array",
@@ -892,10 +894,10 @@
892894
"type": "string",
893895
"description": "The commit body (everything after the subject line)."
894896
},
895-
"author_name": {
897+
"authorName": {
896898
"type": "string"
897899
},
898-
"author_email": {
900+
"authorEmail": {
899901
"type": "string"
900902
}
901903
},
@@ -905,8 +907,8 @@
905907
"message",
906908
"refs",
907909
"body",
908-
"author_name",
909-
"author_email"
910+
"authorName",
911+
"authorEmail"
910912
]
911913
},
912914
"PublicListCommitsResponse": {
@@ -915,6 +917,54 @@
915917
"$ref": "#/components/schemas/PublicCommit"
916918
}
917919
},
920+
"PublicCommitDetail": {
921+
"type": "object",
922+
"properties": {
923+
"hash": {
924+
"type": "string",
925+
"description": "The full commit SHA."
926+
},
927+
"date": {
928+
"type": "string",
929+
"description": "The commit date in ISO 8601 format."
930+
},
931+
"message": {
932+
"type": "string",
933+
"description": "The commit subject line."
934+
},
935+
"refs": {
936+
"type": "string",
937+
"description": "Refs pointing to this commit (e.g. branch or tag names)."
938+
},
939+
"body": {
940+
"type": "string",
941+
"description": "The commit body (everything after the subject line)."
942+
},
943+
"authorName": {
944+
"type": "string"
945+
},
946+
"authorEmail": {
947+
"type": "string"
948+
},
949+
"parents": {
950+
"type": "array",
951+
"items": {
952+
"type": "string"
953+
},
954+
"description": "The parent commit SHAs."
955+
}
956+
},
957+
"required": [
958+
"hash",
959+
"date",
960+
"message",
961+
"refs",
962+
"body",
963+
"authorName",
964+
"authorEmail",
965+
"parents"
966+
]
967+
},
918968
"PublicEeUser": {
919969
"type": "object",
920970
"properties": {
@@ -1820,6 +1870,80 @@
18201870
}
18211871
}
18221872
},
1873+
"/api/commit": {
1874+
"get": {
1875+
"operationId": "getCommit",
1876+
"tags": [
1877+
"Git"
1878+
],
1879+
"summary": "Get commit details",
1880+
"description": "Returns details for a single commit, including parent commit SHAs.",
1881+
"parameters": [
1882+
{
1883+
"schema": {
1884+
"type": "string",
1885+
"description": "The fully-qualified repository name."
1886+
},
1887+
"required": true,
1888+
"description": "The fully-qualified repository name.",
1889+
"name": "repo",
1890+
"in": "query"
1891+
},
1892+
{
1893+
"schema": {
1894+
"type": "string",
1895+
"description": "The git ref (commit SHA, branch, or tag)."
1896+
},
1897+
"required": true,
1898+
"description": "The git ref (commit SHA, branch, or tag).",
1899+
"name": "ref",
1900+
"in": "query"
1901+
}
1902+
],
1903+
"responses": {
1904+
"200": {
1905+
"description": "Commit details.",
1906+
"content": {
1907+
"application/json": {
1908+
"schema": {
1909+
"$ref": "#/components/schemas/PublicCommitDetail"
1910+
}
1911+
}
1912+
}
1913+
},
1914+
"400": {
1915+
"description": "Invalid query parameters or git ref.",
1916+
"content": {
1917+
"application/json": {
1918+
"schema": {
1919+
"$ref": "#/components/schemas/PublicApiServiceError"
1920+
}
1921+
}
1922+
}
1923+
},
1924+
"404": {
1925+
"description": "Repository or revision not found.",
1926+
"content": {
1927+
"application/json": {
1928+
"schema": {
1929+
"$ref": "#/components/schemas/PublicApiServiceError"
1930+
}
1931+
}
1932+
}
1933+
},
1934+
"500": {
1935+
"description": "Unexpected failure.",
1936+
"content": {
1937+
"application/json": {
1938+
"schema": {
1939+
"$ref": "#/components/schemas/PublicApiServiceError"
1940+
}
1941+
}
1942+
}
1943+
}
1944+
}
1945+
}
1946+
},
18231947
"/api/ee/user": {
18241948
"get": {
18251949
"operationId": "getUser",

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
"group": "Git",
173173
"icon": "code-branch",
174174
"pages": [
175+
"GET /api/commit",
175176
"GET /api/diff",
176177
"GET /api/commits",
177178
"GET /api/source",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getCommit } from "@/features/git";
2+
import { getCommitQueryParamsSchema } from "@/features/git/schemas";
3+
import { apiHandler } from "@/lib/apiHandler";
4+
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
5+
import { isServiceError } from "@/lib/utils";
6+
import { NextRequest } from "next/server";
7+
8+
export const GET = apiHandler(async (request: NextRequest): Promise<Response> => {
9+
const rawParams = Object.fromEntries(
10+
Object.keys(getCommitQueryParamsSchema.shape).map(key => [
11+
key,
12+
request.nextUrl.searchParams.get(key) ?? undefined
13+
])
14+
);
15+
const parsed = getCommitQueryParamsSchema.safeParse(rawParams);
16+
17+
if (!parsed.success) {
18+
return serviceErrorResponse(
19+
queryParamsSchemaValidationError(parsed.error)
20+
);
21+
}
22+
23+
const result = await getCommit(parsed.data);
24+
25+
if (isServiceError(result)) {
26+
return serviceErrorResponse(result);
27+
}
28+
29+
return Response.json(result);
30+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { sew } from "@/middleware/sew";
2+
import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError';
3+
import { withOptionalAuth } from '@/middleware/withAuth';
4+
import { getRepoPath } from '@sourcebot/shared';
5+
import { z } from 'zod';
6+
import { simpleGit } from 'simple-git';
7+
import { commitDetailSchema } from './schemas';
8+
import { isGitRefValid } from './utils';
9+
10+
export type CommitDetail = z.infer<typeof commitDetailSchema>;
11+
12+
type GetCommitRequest = {
13+
repo: string;
14+
ref: string;
15+
}
16+
17+
// Field separator that won't appear in commit data
18+
const FIELD_SEP = '\x1f';
19+
const FORMAT = [
20+
'%H', // hash
21+
'%aI', // author date ISO 8601
22+
'%s', // subject
23+
'%D', // refs
24+
'%b', // body
25+
'%aN', // author name
26+
'%aE', // author email
27+
'%P', // parent hashes (space-separated)
28+
].join(FIELD_SEP);
29+
30+
export const getCommit = async ({
31+
repo: repoName,
32+
ref,
33+
}: GetCommitRequest): Promise<CommitDetail | ServiceError> => sew(() =>
34+
withOptionalAuth(async ({ org, prisma }) => {
35+
const repo = await prisma.repo.findFirst({
36+
where: {
37+
name: repoName,
38+
orgId: org.id,
39+
},
40+
});
41+
42+
if (!repo) {
43+
return notFound(`Repository "${repoName}" not found.`);
44+
}
45+
46+
if (!isGitRefValid(ref)) {
47+
return invalidGitRef(ref);
48+
}
49+
50+
const { path: repoPath } = getRepoPath(repo);
51+
const git = simpleGit().cwd(repoPath);
52+
53+
try {
54+
const output = (await git.raw([
55+
'log',
56+
'-1',
57+
`--format=${FORMAT}`,
58+
ref,
59+
])).trim();
60+
61+
const fields = output.split(FIELD_SEP);
62+
if (fields.length < 8) {
63+
return unexpectedError(`Failed to parse commit data for revision "${ref}".`);
64+
}
65+
66+
const [hash, date, message, refs, body, authorName, authorEmail, parentStr] = fields;
67+
const parents = parentStr.trim() === '' ? [] : parentStr.trim().split(' ');
68+
69+
return {
70+
hash,
71+
date,
72+
message,
73+
refs,
74+
body,
75+
authorName,
76+
authorEmail,
77+
parents,
78+
};
79+
} catch (error: unknown) {
80+
const errorMessage = error instanceof Error ? error.message : String(error);
81+
82+
if (errorMessage.includes('not a git repository')) {
83+
return unexpectedError(
84+
`Invalid git repository at ${repoPath}. ` +
85+
`The directory exists but is not a valid git repository.`
86+
);
87+
}
88+
89+
if (errorMessage.includes('unknown revision') || errorMessage.includes('bad object')) {
90+
return notFound(`Revision "${ref}" not found in repository "${repoName}".`);
91+
}
92+
93+
if (error instanceof Error) {
94+
throw new Error(
95+
`Failed to get commit in repository ${repoName}: ${error.message}`
96+
);
97+
} else {
98+
throw new Error(
99+
`Failed to get commit in repository ${repoName}: ${errorMessage}`
100+
);
101+
}
102+
}
103+
}));

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export interface DiffHunk {
1919
}
2020

2121
export interface FileDiff {
22-
oldPath: string;
23-
newPath: string;
22+
oldPath: string | null;
23+
newPath: string | null;
2424
hunks: DiffHunk[];
2525
}
2626

@@ -67,8 +67,8 @@ export const getDiff = async ({
6767
const files = parseDiff(rawDiff);
6868

6969
const nodes: FileDiff[] = files.map((file) => ({
70-
oldPath: file.from ?? '/dev/null',
71-
newPath: file.to ?? '/dev/null',
70+
oldPath: file.from && file.from !== '/dev/null' ? file.from : null,
71+
newPath: file.to && file.to !== '/dev/null' ? file.to : null,
7272
hunks: file.chunks.map((chunk) => {
7373
// chunk.content is the full @@ header line, e.g.:
7474
// "@@ -7,6 +7,8 @@ some heading text"

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 './getCommitApi';
12
export * from './getDiffApi';
23
export * from './getFilesApi';
34
export * from './getFolderContentsApi';

0 commit comments

Comments
 (0)