Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `/api/commits/authors` to the public API to allow fetching a list of authors for a given path and revision. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
- Added optional `path` query parameter to the `/api/diff` endpoint and `get_diff` MCP tool to restrict diffs to changes touching a specific file. [#1154](https://github.com/sourcebot-dev/sourcebot/pull/1154)
- Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157)
- Added `/api/blame` to the public API to fetch per-line blame information for a file at a given revision. [#1158](https://github.com/sourcebot-dev/sourcebot/pull/1158)

### Fixed
- Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)
Expand Down
175 changes: 175 additions & 0 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,97 @@
"webUrl"
]
},
"PublicFileBlameResponse": {
"type": "object",
"properties": {
"ranges": {
"type": "array",
"items": {
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "The hash of the commit that last modified the lines in this range."
},
"startLine": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"description": "The 1-based line number where the range begins (inclusive)."
},
"lineCount": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"description": "The number of contiguous lines in this range."
}
},
"required": [
"hash",
"startLine",
"lineCount"
]
},
"description": "Contiguous, non-overlapping line ranges ordered by startLine. Each range is attributed to a single commit."
},
"commits": {
"type": "object",
"additionalProperties": {
"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."
},
"authorName": {
"type": "string"
},
"authorEmail": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "The hash of the commit that previously affected these lines (i.e., the next step backwards in the blame walk)."
},
"path": {
"type": "string",
"description": "The file path as it existed at the previous commit. May differ from the current path due to renames."
}
},
"required": [
"hash",
"path"
],
"description": "Pointer to the previous commit that affected these lines, with the file path as it existed there. Absent when the commit introduced the lines (no earlier history to walk to)."
}
},
"required": [
"hash",
"date",
"message",
"authorName",
"authorEmail"
]
},
"description": "Commit metadata keyed by hash, deduplicated across ranges."
}
},
"required": [
"ranges",
"commits"
]
},
"PublicGetTreeRequest": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1476,6 +1567,90 @@
}
}
},
"/api/blame": {
"get": {
"operationId": "getFileBlame",
"tags": [
"Git"
],
"summary": "Get file blame",
"description": "Returns blame information for a file at a given repository path and optional git ref.\n\nThe response is split into two parts:\n- `ranges`: contiguous, non-overlapping line ranges, each attributed to a single commit. Ordered by `startLine`.\n- `commits`: commit metadata (hash, date, message, author, optional `previous` pointer for walking back through history) keyed by hash, deduplicated across ranges.\n\nWhole-file renames are followed automatically. Cross-file line moves and copies are not.",
"parameters": [
{
"schema": {
"type": "string",
"description": "The file path to blame, relative to the repository root."
},
"required": true,
"description": "The file path to blame, relative to the repository root.",
"name": "path",
"in": "query"
},
{
"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 (branch, tag, or commit SHA) to blame at. Defaults to the repository's default branch."
},
"required": false,
"description": "The git ref (branch, tag, or commit SHA) to blame at. Defaults to the repository's default branch.",
"name": "ref",
"in": "query"
}
],
"responses": {
"200": {
"description": "Blame ranges and deduplicated commit metadata.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicFileBlameResponse"
}
}
}
},
"400": {
"description": "Invalid query parameters or git ref.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"404": {
"description": "Repository or file not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"500": {
"description": "Unexpected blame retrieval failure.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
}
}
}
},
"/api/tree": {
"post": {
"operationId": "getFileTree",
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"GET /api/commits",
"GET /api/commits/authors",
"GET /api/source",
"GET /api/blame",
"POST /api/tree"
]
},
Expand Down
1 change: 1 addition & 0 deletions docs/docs/configuration/audit-logs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
| `user.fetched_file_blame` | `user` | `org` |
| `user.fetched_file_source` | `user` | `org` |
| `user.fetched_file_tree` | `user` | `org` |
| `user.invite_accept_failed` | `user` | `invite` |
Expand Down
37 changes: 37 additions & 0 deletions packages/web/src/app/api/(server)/blame/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use server';

import { getFileBlame } from '@/features/git';
import { fileBlameRequestSchema } 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) => {
const rawParams = Object.fromEntries(
Object.keys(fileBlameRequestSchema.shape).map(key => [
key,
request.nextUrl.searchParams.get(key) ?? undefined
])
);
const parsed = fileBlameRequestSchema.safeParse(rawParams);

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

const { repo, path, ref } = parsed.data;
const response = await getFileBlame({
path,
repo,
ref,
});

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

return Response.json(response);
});
Loading
Loading