Skip to content

Commit 342e16e

Browse files
committed
feat: implement cursor-based pagination for get_file_blame tool
1 parent 0b09f93 commit 342e16e

4 files changed

Lines changed: 160 additions & 58 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1225,9 +1225,9 @@ The following sets of tools are available:
12251225

12261226
- **get_file_blame** - Get file blame information
12271227
- **Required OAuth Scopes**: `repo`
1228+
- `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
12281229
- `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional)
12291230
- `owner`: Repository owner (username or organization) (string, required)
1230-
- `page`: Page number for pagination (min 1) (number, optional)
12311231
- `path`: Path to the file in the repository, relative to the repository root (string, required)
12321232
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
12331233
- `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional)

pkg/github/__toolsnaps__/get_file_blame.snap

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
"readOnlyHint": true,
44
"title": "Get file blame information"
55
},
6-
"description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'page'/'perPage' to page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.",
6+
"description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.",
77
"inputSchema": {
88
"properties": {
9+
"after": {
10+
"description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
11+
"type": "string"
12+
},
913
"end_line": {
1014
"description": "Optional 1-based ending line of the window of interest. Must be \u003e= start_line when both are provided.",
1115
"minimum": 1,
@@ -15,11 +19,6 @@
1519
"description": "Repository owner (username or organization)",
1620
"type": "string"
1721
},
18-
"page": {
19-
"description": "Page number for pagination (min 1)",
20-
"minimum": 1,
21-
"type": "number"
22-
},
2322
"path": {
2423
"description": "Path to the file in the repository, relative to the repository root",
2524
"type": "string"

pkg/github/repositories.go

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"net/http"
1010
"slices"
11+
"strconv"
1112
"strings"
1213

1314
ghErrors "github.com/github/github-mcp-server/pkg/errors"
@@ -2208,6 +2209,35 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
22082209
// maxBlameRanges caps the number of matching blame ranges considered for one response.
22092210
const maxBlameRanges = 1000
22102211

2212+
const blameCursorPrefix = "blame-range:"
2213+
2214+
func encodeBlameCursor(offset int) string {
2215+
return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s%d", blameCursorPrefix, offset))
2216+
}
2217+
2218+
func decodeBlameCursor(cursor string) (int, error) {
2219+
if cursor == "" {
2220+
return 0, nil
2221+
}
2222+
2223+
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
2224+
if err != nil {
2225+
return 0, fmt.Errorf("after cursor is invalid")
2226+
}
2227+
2228+
value := string(decoded)
2229+
if !strings.HasPrefix(value, blameCursorPrefix) {
2230+
return 0, fmt.Errorf("after cursor is invalid")
2231+
}
2232+
2233+
offset, err := strconv.Atoi(strings.TrimPrefix(value, blameCursorPrefix))
2234+
if err != nil || offset < 0 {
2235+
return 0, fmt.Errorf("after cursor is invalid")
2236+
}
2237+
2238+
return offset, nil
2239+
}
2240+
22112241
// BlameAuthor describes the author of a commit referenced by a BlameRange.
22122242
type BlameAuthor struct {
22132243
Name string `json:"name"`
@@ -2238,14 +2268,15 @@ type BlameRange struct {
22382268

22392269
// BlameResult is the response payload returned by the get_file_blame tool.
22402270
//
2241-
// Commits is keyed by SHA. TotalRanges counts matching ranges before
2271+
// Commits is keyed by SHA. TotalRanges counts matching ranges before cursor
22422272
// pagination or truncation. Truncated reports whether maxBlameRanges was hit.
22432273
type BlameResult struct {
22442274
Repository string `json:"repository"`
22452275
Path string `json:"path"`
22462276
Ref string `json:"ref"`
22472277
Ranges []BlameRange `json:"ranges"`
22482278
Commits map[string]BlameCommit `json:"commits"`
2279+
PageInfo MinimalPageInfo `json:"pageInfo"`
22492280
TotalRanges int `json:"total_ranges"`
22502281
Truncated bool `json:"truncated,omitempty"`
22512282
}
@@ -2279,14 +2310,14 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
22792310
"Get git blame information for a file, showing the commit that last modified each line. "+
22802311
"Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+
22812312
"Use 'start_line'/'end_line' to restrict the result to a window of the file, and "+
2282-
"'page'/'perPage' to page through returned ranges. Matching ranges are capped at "+
2313+
"'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at "+
22832314
"1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.",
22842315
),
22852316
Annotations: &mcp.ToolAnnotations{
22862317
Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"),
22872318
ReadOnlyHint: true,
22882319
},
2289-
InputSchema: WithPagination(&jsonschema.Schema{
2320+
InputSchema: WithCursorPagination(&jsonschema.Schema{
22902321
Type: "object",
22912322
Properties: map[string]*jsonschema.Schema{
22922323
"owner": {
@@ -2359,25 +2390,26 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
23592390
if hasStartLine && hasEndLine && endLine < startLine {
23602391
return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil
23612392
}
2362-
page := 1
23632393
if _, hasPage := args["page"]; hasPage {
2364-
page, err = OptionalIntParam(args, "page")
2365-
if err != nil {
2366-
return utils.NewToolResultError(err.Error()), nil, nil
2367-
}
2368-
if page < 1 {
2369-
return utils.NewToolResultError("page must be >= 1 when provided"), nil, nil
2370-
}
2394+
return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil
2395+
}
2396+
pagination, err := OptionalCursorPaginationParams(args)
2397+
if err != nil {
2398+
return utils.NewToolResultError(err.Error()), nil, nil
23712399
}
2372-
perPage := 30
23732400
if _, hasPerPage := args["perPage"]; hasPerPage {
2374-
perPage, err = OptionalIntParam(args, "perPage")
2401+
perPage, err := OptionalIntParam(args, "perPage")
23752402
if err != nil {
23762403
return utils.NewToolResultError(err.Error()), nil, nil
23772404
}
23782405
if perPage < 1 || perPage > 100 {
23792406
return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil
23802407
}
2408+
pagination.PerPage = perPage
2409+
}
2410+
afterOffset, err := decodeBlameCursor(pagination.After)
2411+
if err != nil {
2412+
return utils.NewToolResultError(err.Error()), nil, nil
23812413
}
23822414

23832415
client, err := deps.GetGQLClient(ctx)
@@ -2465,9 +2497,11 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
24652497
}
24662498

24672499
rawRanges := blameQuery.Repository.Object.Commit.Blame.Ranges
2500+
pageRanges := make([]BlameRange, 0, pagination.PerPage)
2501+
commits := make(map[string]BlameCommit)
2502+
totalRanges := 0
2503+
truncated := false
24682504

2469-
// Filter / clamp to the requested line window.
2470-
windowed := make([]BlameRange, 0, len(rawRanges))
24712505
for _, r := range rawRanges {
24722506
start := int(r.StartingLine)
24732507
end := int(r.EndingLine)
@@ -2483,40 +2517,26 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
24832517
if endLine > 0 && end > endLine {
24842518
end = endLine
24852519
}
2486-
windowed = append(windowed, BlameRange{
2520+
2521+
matchIndex := totalRanges
2522+
totalRanges++
2523+
if matchIndex >= maxBlameRanges {
2524+
truncated = true
2525+
continue
2526+
}
2527+
if matchIndex < afterOffset || len(pageRanges) >= pagination.PerPage {
2528+
continue
2529+
}
2530+
2531+
blameRange := BlameRange{
24872532
StartingLine: start,
24882533
EndingLine: end,
24892534
Age: int(r.Age),
24902535
CommitSHA: string(r.Commit.OID),
2491-
})
2492-
}
2493-
2494-
totalRanges := len(windowed)
2495-
2496-
// Cap before pagination so truncation is reported consistently.
2497-
truncated := false
2498-
if len(windowed) > maxBlameRanges {
2499-
truncated = true
2500-
windowed = windowed[:maxBlameRanges]
2501-
}
2502-
2503-
// Apply page/perPage pagination over the (filtered, capped) set.
2504-
cappedRanges := len(windowed)
2505-
offset := min((page-1)*perPage, cappedRanges)
2506-
end := min(offset+perPage, cappedRanges)
2507-
pageRanges := windowed[offset:end]
2536+
}
2537+
pageRanges = append(pageRanges, blameRange)
25082538

2509-
// Collect commit metadata only for SHAs referenced by this page.
2510-
needed := make(map[string]struct{}, len(pageRanges))
2511-
for _, br := range pageRanges {
2512-
needed[br.CommitSHA] = struct{}{}
2513-
}
2514-
commits := make(map[string]BlameCommit, len(needed))
2515-
for _, r := range rawRanges {
25162539
sha := string(r.Commit.OID)
2517-
if _, want := needed[sha]; !want {
2518-
continue
2519-
}
25202540
if _, seen := commits[sha]; seen {
25212541
continue
25222542
}
@@ -2543,12 +2563,24 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
25432563
commits[sha] = bc
25442564
}
25452565

2566+
cappedRanges := min(totalRanges, maxBlameRanges)
2567+
consumedRanges := min(afterOffset+len(pageRanges), cappedRanges)
2568+
pageInfo := MinimalPageInfo{
2569+
HasNextPage: consumedRanges < cappedRanges,
2570+
HasPreviousPage: afterOffset > 0,
2571+
}
2572+
if len(pageRanges) > 0 {
2573+
pageInfo.StartCursor = encodeBlameCursor(afterOffset)
2574+
pageInfo.EndCursor = encodeBlameCursor(consumedRanges)
2575+
}
2576+
25462577
result := BlameResult{
25472578
Repository: fmt.Sprintf("%s/%s", owner, repo),
25482579
Path: path,
25492580
Ref: responseRef,
25502581
Ranges: pageRanges,
25512582
Commits: commits,
2583+
PageInfo: pageInfo,
25522584
TotalRanges: totalRanges,
25532585
Truncated: truncated,
25542586
}

pkg/github/repositories_test.go

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4380,9 +4380,10 @@ func Test_GetFileBlame(t *testing.T) {
43804380
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
43814381
assert.Equal(t, "get_file_blame", tool.Name)
43824382
assert.NotEmpty(t, tool.Description)
4383-
for _, key := range []string{"owner", "repo", "path", "ref", "start_line", "end_line", "page", "perPage"} {
4383+
for _, key := range []string{"owner", "repo", "path", "ref", "start_line", "end_line", "perPage", "after"} {
43844384
assert.Contains(t, schema.Properties, key, "schema missing property %q", key)
43854385
}
4386+
assert.NotContains(t, schema.Properties, "page")
43864387
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path"})
43874388
require.NotNil(t, tool.Annotations)
43884389
assert.True(t, tool.Annotations.ReadOnlyHint, "blame is read-only")
@@ -4510,6 +4511,10 @@ func Test_GetFileBlame(t *testing.T) {
45104511
assert.Equal(t, "main", br.Ref, "ref should resolve to default branch name")
45114512
assert.False(t, br.Truncated)
45124513
assert.Equal(t, 3, br.TotalRanges)
4514+
assert.False(t, br.PageInfo.HasNextPage)
4515+
assert.False(t, br.PageInfo.HasPreviousPage)
4516+
assert.NotEmpty(t, br.PageInfo.StartCursor)
4517+
assert.NotEmpty(t, br.PageInfo.EndCursor)
45134518
require.Len(t, br.Ranges, 3)
45144519
// Commits map is deduplicated.
45154520
require.Len(t, br.Commits, 2)
@@ -4598,6 +4603,8 @@ func Test_GetFileBlame(t *testing.T) {
45984603
assert.Equal(t, 0, br.TotalRanges)
45994604
assert.Empty(t, br.Ranges)
46004605
assert.Empty(t, br.Commits)
4606+
assert.False(t, br.PageInfo.HasNextPage)
4607+
assert.False(t, br.PageInfo.HasPreviousPage)
46014608
assert.False(t, br.Truncated)
46024609
// Ranges should marshal as an empty array, not null.
46034610
assert.Contains(t, result, `"ranges":[]`)
@@ -4715,6 +4722,68 @@ func Test_GetFileBlame(t *testing.T) {
47154722
assert.NotContains(t, br.Commits, "sha-A", "filtered-out commit must not appear")
47164723
},
47174724
},
4725+
{
4726+
name: "cursor pagination returns requested page",
4727+
mockedClient: githubv4mock.NewMockedHTTPClient(
4728+
githubv4mock.NewQueryMatcher(
4729+
blameQueryShape{},
4730+
makeBlameVars("testowner", "testrepo", "HEAD", "src/paged.go"),
4731+
githubv4mock.DataResponse(map[string]any{
4732+
"repository": map[string]any{
4733+
"defaultBranchRef": map[string]any{"name": "main"},
4734+
"object": map[string]any{
4735+
"__typename": "Commit",
4736+
"blame": map[string]any{
4737+
"ranges": []map[string]any{
4738+
{
4739+
"startingLine": 1, "endingLine": 1, "age": 1,
4740+
"commit": map[string]any{
4741+
"oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z",
4742+
"author": map[string]any{"name": "a", "email": "a@x", "user": nil},
4743+
},
4744+
},
4745+
{
4746+
"startingLine": 2, "endingLine": 2, "age": 1,
4747+
"commit": map[string]any{
4748+
"oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z",
4749+
"author": map[string]any{"name": "b", "email": "b@x", "user": nil},
4750+
},
4751+
},
4752+
{
4753+
"startingLine": 3, "endingLine": 3, "age": 1,
4754+
"commit": map[string]any{
4755+
"oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z",
4756+
"author": map[string]any{"name": "c", "email": "c@x", "user": nil},
4757+
},
4758+
},
4759+
},
4760+
},
4761+
},
4762+
},
4763+
}),
4764+
),
4765+
),
4766+
requestArgs: map[string]any{
4767+
"owner": "testowner",
4768+
"repo": "testrepo",
4769+
"path": "src/paged.go",
4770+
"perPage": float64(1),
4771+
"after": encodeBlameCursor(1),
4772+
},
4773+
validateResponse: func(t *testing.T, result string) {
4774+
var br BlameResult
4775+
require.NoError(t, json.Unmarshal([]byte(result), &br))
4776+
assert.Equal(t, 3, br.TotalRanges)
4777+
require.Len(t, br.Ranges, 1)
4778+
assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA)
4779+
require.Len(t, br.Commits, 1)
4780+
require.Contains(t, br.Commits, "sha-B")
4781+
assert.True(t, br.PageInfo.HasNextPage)
4782+
assert.True(t, br.PageInfo.HasPreviousPage)
4783+
assert.Equal(t, encodeBlameCursor(1), br.PageInfo.StartCursor)
4784+
assert.Equal(t, encodeBlameCursor(2), br.PageInfo.EndCursor)
4785+
},
4786+
},
47184787
{
47194788
name: "GraphQL error is surfaced",
47204789
mockedClient: githubv4mock.NewMockedHTTPClient(
@@ -4791,7 +4860,7 @@ func Test_GetFileBlame(t *testing.T) {
47914860
}
47924861
})
47934862

4794-
// Line-window and pagination validation also short-circuits.
4863+
// Line-window and cursor pagination validation also short-circuits.
47954864
t.Run("line-range argument validation", func(t *testing.T) {
47964865
client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
47974866
deps := BaseDeps{GQLClient: client}
@@ -4818,14 +4887,14 @@ func Test_GetFileBlame(t *testing.T) {
48184887
"end_line must be omitted or >= 1",
48194888
},
48204889
{
4821-
"invalid page",
4822-
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(-1)},
4823-
"page must be >= 1 when provided",
4890+
"page not supported",
4891+
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(1)},
4892+
"cursor-based pagination",
48244893
},
48254894
{
4826-
"page zero",
4827-
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(0)},
4828-
"page must be >= 1 when provided",
4895+
"invalid after cursor",
4896+
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "after": "not-a-cursor"},
4897+
"after cursor is invalid",
48294898
},
48304899
{
48314900
"perPage too large",
@@ -4896,6 +4965,8 @@ func Test_GetFileBlame(t *testing.T) {
48964965
assert.True(t, br.Truncated, "truncation flag must be set")
48974966
assert.Equal(t, maxBlameRanges+5, br.TotalRanges)
48984967
assert.Len(t, br.Ranges, 100, "perPage limits the page size")
4968+
assert.True(t, br.PageInfo.HasNextPage)
4969+
assert.NotEmpty(t, br.PageInfo.EndCursor)
48994970
})
49004971
}
49014972

0 commit comments

Comments
 (0)