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
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