Skip to content

Commit 4c16b34

Browse files
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>
1 parent b537325 commit 4c16b34

9 files changed

Lines changed: 471 additions & 0 deletions

File tree

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)