Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- 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)

### Changed
- Replaced placeholder avatars with deterministic minidenticon-based avatars generated from email addresses [#1072](https://github.com/sourcebot-dev/sourcebot/pull/1072)
- 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)
- 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)

## [4.16.4] - 2026-04-01

Expand Down
136 changes: 130 additions & 6 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -619,11 +619,13 @@
"properties": {
"oldPath": {
"type": "string",
"description": "The file path before the change. `/dev/null` for added files."
"nullable": true,
"description": "The file path before the change. `null` for added files."
},
"newPath": {
"type": "string",
"description": "The file path after the change. `/dev/null` for deleted files."
"nullable": true,
"description": "The file path after the change. `null` for deleted files."
},
"hunks": {
"type": "array",
Expand Down Expand Up @@ -892,10 +894,10 @@
"type": "string",
"description": "The commit body (everything after the subject line)."
},
"author_name": {
"authorName": {
"type": "string"
},
"author_email": {
"authorEmail": {
"type": "string"
}
},
Expand All @@ -905,8 +907,8 @@
"message",
"refs",
"body",
"author_name",
"author_email"
"authorName",
"authorEmail"
]
},
"PublicListCommitsResponse": {
Expand All @@ -915,6 +917,54 @@
"$ref": "#/components/schemas/PublicCommit"
}
},
"PublicCommitDetail": {
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "The full commit SHA."
},
"date": {
"type": "string",
"description": "The commit date in ISO 8601 format."
},
"message": {
"type": "string",
"description": "The commit subject line."
},
"refs": {
"type": "string",
"description": "Refs pointing to this commit (e.g. branch or tag names)."
},
"body": {
"type": "string",
"description": "The commit body (everything after the subject line)."
},
"authorName": {
"type": "string"
},
"authorEmail": {
"type": "string"
},
"parents": {
"type": "array",
"items": {
"type": "string"
},
"description": "The parent commit SHAs."
}
},
"required": [
"hash",
"date",
"message",
"refs",
"body",
"authorName",
"authorEmail",
"parents"
]
},
"PublicEeUser": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1820,6 +1870,80 @@
}
}
},
"/api/commit": {
"get": {
"operationId": "getCommit",
"tags": [
"Git"
],
"summary": "Get commit details",
"description": "Returns details for a single commit, including parent commit SHAs.",
"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 git ref (commit SHA, branch, or tag)."
},
"required": true,
"description": "The git ref (commit SHA, branch, or tag).",
"name": "ref",
"in": "query"
}
],
"responses": {
"200": {
"description": "Commit details.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicCommitDetail"
}
}
}
},
"400": {
"description": "Invalid query parameters or git ref.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"404": {
"description": "Repository or revision not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"500": {
"description": "Unexpected failure.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
}
}
}
},
"/api/ee/user": {
"get": {
"operationId": "getUser",
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
"group": "Git",
"icon": "code-branch",
"pages": [
"GET /api/commit",
"GET /api/diff",
"GET /api/commits",
"GET /api/source",
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,8 @@ export const listCommitsResponseSchema = z.array(z.object({
message: z.string(),
refs: z.string(),
body: z.string(),
author_name: z.string(),
author_email: z.string(),
authorName: z.string(),
authorEmail: z.string(),
}));

export const languageModelInfoSchema = z.object({
Expand Down
30 changes: 30 additions & 0 deletions packages/web/src/app/api/(server)/commit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getCommit } from "@/features/git";
import { getCommitQueryParamsSchema } from "@/features/git/schemas";
import { apiHandler } from "@/lib/apiHandler";
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";

export const GET = apiHandler(async (request: NextRequest): Promise<Response> => {
const rawParams = Object.fromEntries(
Object.keys(getCommitQueryParamsSchema.shape).map(key => [
key,
request.nextUrl.searchParams.get(key) ?? undefined
])
);
const parsed = getCommitQueryParamsSchema.safeParse(rawParams);

if (!parsed.success) {
return serviceErrorResponse(
queryParamsSchemaValidationError(parsed.error)
);
}

const result = await getCommit(parsed.data);

if (isServiceError(result)) {
return serviceErrorResponse(result);
}

return Response.json(result);
});
103 changes: 103 additions & 0 deletions packages/web/src/features/git/getCommitApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { sew } from "@/middleware/sew";
import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError';
import { withOptionalAuth } from '@/middleware/withAuth';
import { getRepoPath } from '@sourcebot/shared';
import { z } from 'zod';
import { simpleGit } from 'simple-git';
import { commitDetailSchema } from './schemas';
import { isGitRefValid } from './utils';

export type CommitDetail = z.infer<typeof commitDetailSchema>;

type GetCommitRequest = {
repo: string;
ref: string;
}

// Field separator that won't appear in commit data
const FIELD_SEP = '\x1f';
const FORMAT = [
'%H', // hash
'%aI', // author date ISO 8601
'%s', // subject
'%D', // refs
'%b', // body
'%aN', // author name
'%aE', // author email
'%P', // parent hashes (space-separated)
].join(FIELD_SEP);

export const getCommit = async ({
repo: repoName,
ref,
}: GetCommitRequest): Promise<CommitDetail | ServiceError> => sew(() =>
withOptionalAuth(async ({ org, prisma }) => {
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});

if (!repo) {
return notFound(`Repository "${repoName}" not found.`);
}

if (!isGitRefValid(ref)) {
return invalidGitRef(ref);
}

const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);

try {
const output = (await git.raw([
'log',
'-1',
`--format=${FORMAT}`,
ref,
])).trim();

const fields = output.split(FIELD_SEP);
if (fields.length < 8) {
return unexpectedError(`Failed to parse commit data for revision "${ref}".`);
}

const [hash, date, message, refs, body, authorName, authorEmail, parentStr] = fields;
const parents = parentStr.trim() === '' ? [] : parentStr.trim().split(' ');

return {
hash,
date,
message,
refs,
body,
authorName,
authorEmail,
parents,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);

if (errorMessage.includes('not a git repository')) {
return unexpectedError(
`Invalid git repository at ${repoPath}. ` +
`The directory exists but is not a valid git repository.`
);
}

if (errorMessage.includes('unknown revision') || errorMessage.includes('bad object')) {
return notFound(`Revision "${ref}" not found in repository "${repoName}".`);
}

if (error instanceof Error) {
throw new Error(
`Failed to get commit in repository ${repoName}: ${error.message}`
);
} else {
throw new Error(
`Failed to get commit in repository ${repoName}: ${errorMessage}`
);
}
}
}));
8 changes: 4 additions & 4 deletions packages/web/src/features/git/getDiffApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export interface DiffHunk {
}

export interface FileDiff {
oldPath: string;
newPath: string;
oldPath: string | null;
newPath: string | null;
hunks: DiffHunk[];
}

Expand Down Expand Up @@ -67,8 +67,8 @@ export const getDiff = async ({
const files = parseDiff(rawDiff);

const nodes: FileDiff[] = files.map((file) => ({
oldPath: file.from ?? '/dev/null',
newPath: file.to ?? '/dev/null',
oldPath: file.from && file.from !== '/dev/null' ? file.from : null,
newPath: file.to && file.to !== '/dev/null' ? file.to : null,
hunks: file.chunks.map((chunk) => {
// chunk.content is the full @@ header line, e.g.:
// "@@ -7,6 +7,8 @@ some heading text"
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/features/git/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './getCommitApi';
export * from './getDiffApi';
export * from './getFilesApi';
export * from './getFolderContentsApi';
Expand Down
Loading
Loading