Skip to content

Commit cbf50e7

Browse files
feat(web): add /api/blame endpoint (#1158)
* feat(web): add /api/blame endpoint and getFileBlame helper Adds a new git helper (`getFileBlame`) that runs `git blame --porcelain` and parses the output into contiguous line ranges plus deduplicated commit metadata (hash, date, message, author, optional `previous` pointer for walking back through history). Exposes it as a new public REST endpoint `GET /api/blame`, mirroring the shape of `/api/source`. Registers OpenAPI schemas, updates the API Reference nav, and adds a `user.fetched_file_blame` audit event. API-only; the CodeMirror gutter UI is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(web): add CHANGELOG entry for /api/blame Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b537325 commit cbf50e7

10 files changed

Lines changed: 472 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- 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)
1414
- 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)
1515
- Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157)
16+
- 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)
1617

1718
### Fixed
1819
- Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,97 @@
587587
"webUrl"
588588
]
589589
},
590+
"PublicFileBlameResponse": {
591+
"type": "object",
592+
"properties": {
593+
"ranges": {
594+
"type": "array",
595+
"items": {
596+
"type": "object",
597+
"properties": {
598+
"hash": {
599+
"type": "string",
600+
"description": "The hash of the commit that last modified the lines in this range."
601+
},
602+
"startLine": {
603+
"type": "integer",
604+
"minimum": 0,
605+
"exclusiveMinimum": true,
606+
"description": "The 1-based line number where the range begins (inclusive)."
607+
},
608+
"lineCount": {
609+
"type": "integer",
610+
"minimum": 0,
611+
"exclusiveMinimum": true,
612+
"description": "The number of contiguous lines in this range."
613+
}
614+
},
615+
"required": [
616+
"hash",
617+
"startLine",
618+
"lineCount"
619+
]
620+
},
621+
"description": "Contiguous, non-overlapping line ranges ordered by startLine. Each range is attributed to a single commit."
622+
},
623+
"commits": {
624+
"type": "object",
625+
"additionalProperties": {
626+
"type": "object",
627+
"properties": {
628+
"hash": {
629+
"type": "string",
630+
"description": "The full commit SHA."
631+
},
632+
"date": {
633+
"type": "string",
634+
"description": "The commit date in ISO 8601 format."
635+
},
636+
"message": {
637+
"type": "string",
638+
"description": "The commit subject line."
639+
},
640+
"authorName": {
641+
"type": "string"
642+
},
643+
"authorEmail": {
644+
"type": "string"
645+
},
646+
"previous": {
647+
"type": "object",
648+
"properties": {
649+
"hash": {
650+
"type": "string",
651+
"description": "The hash of the commit that previously affected these lines (i.e., the next step backwards in the blame walk)."
652+
},
653+
"path": {
654+
"type": "string",
655+
"description": "The file path as it existed at the previous commit. May differ from the current path due to renames."
656+
}
657+
},
658+
"required": [
659+
"hash",
660+
"path"
661+
],
662+
"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)."
663+
}
664+
},
665+
"required": [
666+
"hash",
667+
"date",
668+
"message",
669+
"authorName",
670+
"authorEmail"
671+
]
672+
},
673+
"description": "Commit metadata keyed by hash, deduplicated across ranges."
674+
}
675+
},
676+
"required": [
677+
"ranges",
678+
"commits"
679+
]
680+
},
590681
"PublicGetTreeRequest": {
591682
"type": "object",
592683
"properties": {
@@ -1476,6 +1567,90 @@
14761567
}
14771568
}
14781569
},
1570+
"/api/blame": {
1571+
"get": {
1572+
"operationId": "getFileBlame",
1573+
"tags": [
1574+
"Git"
1575+
],
1576+
"summary": "Get file blame",
1577+
"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.",
1578+
"parameters": [
1579+
{
1580+
"schema": {
1581+
"type": "string",
1582+
"description": "The file path to blame, relative to the repository root."
1583+
},
1584+
"required": true,
1585+
"description": "The file path to blame, relative to the repository root.",
1586+
"name": "path",
1587+
"in": "query"
1588+
},
1589+
{
1590+
"schema": {
1591+
"type": "string",
1592+
"description": "The fully-qualified repository name."
1593+
},
1594+
"required": true,
1595+
"description": "The fully-qualified repository name.",
1596+
"name": "repo",
1597+
"in": "query"
1598+
},
1599+
{
1600+
"schema": {
1601+
"type": "string",
1602+
"description": "The git ref (branch, tag, or commit SHA) to blame at. Defaults to the repository's default branch."
1603+
},
1604+
"required": false,
1605+
"description": "The git ref (branch, tag, or commit SHA) to blame at. Defaults to the repository's default branch.",
1606+
"name": "ref",
1607+
"in": "query"
1608+
}
1609+
],
1610+
"responses": {
1611+
"200": {
1612+
"description": "Blame ranges and deduplicated commit metadata.",
1613+
"content": {
1614+
"application/json": {
1615+
"schema": {
1616+
"$ref": "#/components/schemas/PublicFileBlameResponse"
1617+
}
1618+
}
1619+
}
1620+
},
1621+
"400": {
1622+
"description": "Invalid query parameters or git ref.",
1623+
"content": {
1624+
"application/json": {
1625+
"schema": {
1626+
"$ref": "#/components/schemas/PublicApiServiceError"
1627+
}
1628+
}
1629+
}
1630+
},
1631+
"404": {
1632+
"description": "Repository or file not found.",
1633+
"content": {
1634+
"application/json": {
1635+
"schema": {
1636+
"$ref": "#/components/schemas/PublicApiServiceError"
1637+
}
1638+
}
1639+
}
1640+
},
1641+
"500": {
1642+
"description": "Unexpected blame retrieval failure.",
1643+
"content": {
1644+
"application/json": {
1645+
"schema": {
1646+
"$ref": "#/components/schemas/PublicApiServiceError"
1647+
}
1648+
}
1649+
}
1650+
}
1651+
}
1652+
}
1653+
},
14791654
"/api/tree": {
14801655
"post": {
14811656
"operationId": "getFileTree",

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
"GET /api/commits",
178178
"GET /api/commits/authors",
179179
"GET /api/source",
180+
"GET /api/blame",
180181
"POST /api/tree"
181182
]
182183
},

docs/docs/configuration/audit-logs.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
125125
| `user.created_ask_chat` | `user` | `org` |
126126
| `user.creation_failed` | `user` | `user` |
127127
| `user.delete` | `user` | `user` |
128+
| `user.fetched_file_blame` | `user` | `org` |
128129
| `user.fetched_file_source` | `user` | `org` |
129130
| `user.fetched_file_tree` | `user` | `org` |
130131
| `user.invite_accept_failed` | `user` | `invite` |
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use server';
2+
3+
import { getFileBlame } from '@/features/git';
4+
import { fileBlameRequestSchema } from '@/features/git/schemas';
5+
import { apiHandler } from "@/lib/apiHandler";
6+
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
7+
import { isServiceError } from "@/lib/utils";
8+
import { NextRequest } from "next/server";
9+
10+
export const GET = apiHandler(async (request: NextRequest) => {
11+
const rawParams = Object.fromEntries(
12+
Object.keys(fileBlameRequestSchema.shape).map(key => [
13+
key,
14+
request.nextUrl.searchParams.get(key) ?? undefined
15+
])
16+
);
17+
const parsed = fileBlameRequestSchema.safeParse(rawParams);
18+
19+
if (!parsed.success) {
20+
return serviceErrorResponse(
21+
queryParamsSchemaValidationError(parsed.error)
22+
);
23+
}
24+
25+
const { repo, path, ref } = parsed.data;
26+
const response = await getFileBlame({
27+
path,
28+
repo,
29+
ref,
30+
});
31+
32+
if (isServiceError(response)) {
33+
return serviceErrorResponse(response);
34+
}
35+
36+
return Response.json(response);
37+
});

0 commit comments

Comments
 (0)