Skip to content

Commit e3809fe

Browse files
A-GeorgiouCopilot
andauthored
Add cursor pagination to list_label (#3505)
* Return every label from list_label instead of the first 100 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8a9000c-ebe8-4118-ae89-a942861e4166 * Page list_label with cursors instead of fetching every label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8a9000c-ebe8-4118-ae89-a942861e4166 * Match list_issues wording in the list_label description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8a9000c-ebe8-4118-ae89-a942861e4166 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8a9000c-ebe8-4118-ae89-a942861e4166
1 parent 796afb7 commit e3809fe

6 files changed

Lines changed: 184 additions & 55 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,7 +1126,9 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
11261126

11271127
- **list_label** - List labels from a repository
11281128
- **OAuth Challenge Scopes**: `repo`
1129+
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
11291130
- `owner`: Repository owner (username or organization name) - required for all operations (string, required)
1131+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
11301132
- `repo`: Repository name - required for all operations (string, required)
11311133

11321134
</details>

‎pkg/github/__toolsnaps__/list_label.snap‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,23 @@
44
"readOnlyHint": true,
55
"title": "List labels from a repository"
66
},
7-
"description": "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first",
7+
"description": "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.",
88
"inputSchema": {
99
"properties": {
10+
"after": {
11+
"description": "Cursor for pagination. Use the cursor from the previous response.",
12+
"type": "string"
13+
},
1014
"owner": {
1115
"description": "Repository owner (username or organization name) - required for all operations",
1216
"type": "string"
1317
},
18+
"perPage": {
19+
"description": "Results per page for pagination (min 1, max 100)",
20+
"maximum": 100,
21+
"minimum": 1,
22+
"type": "number"
23+
},
1424
"repo": {
1525
"description": "Repository name - required for all operations",
1626
"type": "string"
@@ -53,14 +63,37 @@
5363
"array"
5464
]
5565
},
66+
"pageInfo": {
67+
"additionalProperties": false,
68+
"properties": {
69+
"endCursor": {
70+
"type": "string"
71+
},
72+
"hasNextPage": {
73+
"type": "boolean"
74+
},
75+
"hasPreviousPage": {
76+
"type": "boolean"
77+
},
78+
"startCursor": {
79+
"type": "string"
80+
}
81+
},
82+
"required": [
83+
"hasNextPage",
84+
"hasPreviousPage"
85+
],
86+
"type": "object"
87+
},
5688
"totalCount": {
5789
"description": "Total labels in the repository.",
5890
"type": "integer"
5991
}
6092
},
6193
"required": [
6294
"labels",
63-
"totalCount"
95+
"totalCount",
96+
"pageInfo"
6497
],
6598
"type": "object"
6699
}

‎pkg/github/labels.go‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.Se
132132
return tool
133133
}
134134

135+
// listLabelsDefaultPerPage keeps the first page as large as GraphQL allows, so most repositories need one call.
136+
const listLabelsDefaultPerPage = 100
137+
135138
// ListLabels lists labels from a repository
136139
func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
137140
return typedGovernanceTool(listLabelsLegacy(t), listLabelsOutputSchema(), decodeGovernanceJSON[ListLabelsOutput])
@@ -142,12 +145,12 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
142145
ToolsetLabels,
143146
mcp.Tool{
144147
Name: "list_label",
145-
Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first"),
148+
Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."),
146149
Annotations: &mcp.ToolAnnotations{
147150
Title: t("TOOL_LIST_LABEL_TITLE", "List labels from a repository"),
148151
ReadOnlyHint: true,
149152
},
150-
InputSchema: &jsonschema.Schema{
153+
InputSchema: WithCursorPagination(&jsonschema.Schema{
151154
Type: "object",
152155
Properties: map[string]*jsonschema.Schema{
153156
"owner": {
@@ -160,7 +163,7 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
160163
},
161164
},
162165
Required: []string{"owner", "repo"},
163-
},
166+
}),
164167
},
165168
scopes.PublicRead(scopes.Repo),
166169
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
@@ -174,6 +177,19 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
174177
return utils.NewToolResultError(err.Error()), nil, nil
175178
}
176179

180+
perPage, err := OptionalIntParamWithDefault(args, "perPage", listLabelsDefaultPerPage)
181+
if err != nil {
182+
return utils.NewToolResultError(err.Error()), nil, nil
183+
}
184+
after, err := OptionalParam[string](args, "after")
185+
if err != nil {
186+
return utils.NewToolResultError(err.Error()), nil, nil
187+
}
188+
paginationParams, err := CursorPaginationParams{PerPage: perPage, After: after}.ToGraphQLParams()
189+
if err != nil {
190+
return utils.NewToolResultError(err.Error()), nil, nil
191+
}
192+
177193
client, err := deps.GetGQLClient(ctx)
178194
if err != nil {
179195
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
@@ -189,17 +205,29 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
189205
Description githubv4.String
190206
}
191207
TotalCount githubv4.Int
192-
} `graphql:"labels(first: 100, orderBy: {field: $orderByField, direction: $orderByDirection})"`
208+
PageInfo struct {
209+
HasNextPage githubv4.Boolean
210+
HasPreviousPage githubv4.Boolean
211+
StartCursor githubv4.String
212+
EndCursor githubv4.String
213+
}
214+
} `graphql:"labels(first: $first, after: $after, orderBy: {field: $orderByField, direction: $orderByDirection})"`
193215
} `graphql:"repository(owner: $owner, name: $repo)"`
194216
}
195217

196218
vars := map[string]any{
197219
"owner": githubv4.String(owner),
198220
"repo": githubv4.String(repo),
221+
"first": githubv4.Int(*paginationParams.First),
199222
// Order labels by issue count (descending) so the most-used labels are returned first.
200223
"orderByField": labelOrderFieldIssueCount,
201224
"orderByDirection": githubv4.OrderDirectionDesc,
202225
}
226+
if paginationParams.After != nil {
227+
vars["after"] = githubv4.String(*paginationParams.After)
228+
} else {
229+
vars["after"] = (*githubv4.String)(nil)
230+
}
203231

204232
if err := client.Query(ctx, &query, vars); err != nil {
205233
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
@@ -215,9 +243,16 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
215243
}
216244
}
217245

246+
labelsPage := query.Repository.Labels.PageInfo
218247
response := map[string]any{
219248
"labels": labels,
220249
"totalCount": int(query.Repository.Labels.TotalCount),
250+
"pageInfo": MinimalPageInfo{
251+
HasNextPage: bool(labelsPage.HasNextPage),
252+
HasPreviousPage: bool(labelsPage.HasPreviousPage),
253+
StartCursor: string(labelsPage.StartCursor),
254+
EndCursor: string(labelsPage.EndCursor),
255+
},
221256
}
222257

223258
out, err := json.Marshal(response)

‎pkg/github/labels_test.go‎

Lines changed: 103 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package github
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/http"
67
"testing"
78

@@ -156,58 +157,65 @@ func TestListLabels(t *testing.T) {
156157
mockedClient *http.Client
157158
expectToolError bool
158159
expectedToolErrMsg string
160+
expectedPageInfo MinimalPageInfo
159161
}{
160162
{
161163
name: "successful repository labels listing",
162164
requestArgs: map[string]any{
163165
"owner": "owner",
164166
"repo": "repo",
165167
},
166-
mockedClient: githubv4mock.NewMockedHTTPClient(
167-
githubv4mock.NewQueryMatcher(
168-
struct {
169-
Repository struct {
170-
Labels struct {
171-
Nodes []struct {
172-
ID githubv4.ID
173-
Name githubv4.String
174-
Color githubv4.String
175-
Description githubv4.String
176-
}
177-
TotalCount githubv4.Int
178-
} `graphql:"labels(first: 100, orderBy: {field: $orderByField, direction: $orderByDirection})"`
179-
} `graphql:"repository(owner: $owner, name: $repo)"`
180-
}{},
181-
map[string]any{
182-
"owner": githubv4.String("owner"),
183-
"repo": githubv4.String("repo"),
184-
"orderByField": labelOrderFieldIssueCount,
185-
"orderByDirection": githubv4.OrderDirectionDesc,
186-
},
187-
githubv4mock.DataResponse(map[string]any{
188-
"repository": map[string]any{
189-
"labels": map[string]any{
190-
"nodes": []any{
191-
map[string]any{
192-
"id": githubv4.ID("label-1"),
193-
"name": githubv4.String("bug"),
194-
"color": githubv4.String("d73a4a"),
195-
"description": githubv4.String("Something isn't working"),
196-
},
197-
map[string]any{
198-
"id": githubv4.ID("label-2"),
199-
"name": githubv4.String("enhancement"),
200-
"color": githubv4.String("a2eeef"),
201-
"description": githubv4.String("New feature or request"),
202-
},
203-
},
204-
"totalCount": githubv4.Int(2),
205-
},
206-
},
207-
}),
208-
),
209-
),
210-
expectToolError: false,
168+
mockedClient: githubv4mock.NewMockedHTTPClient(listLabelsMatcher(100, (*githubv4.String)(nil), map[string]any{
169+
"hasNextPage": githubv4.Boolean(false),
170+
"hasPreviousPage": githubv4.Boolean(false),
171+
"startCursor": githubv4.String("c1"),
172+
"endCursor": githubv4.String("c2"),
173+
})),
174+
expectToolError: false,
175+
expectedPageInfo: MinimalPageInfo{StartCursor: "c1", EndCursor: "c2"},
176+
},
177+
{
178+
name: "reports the next page when the repository has more labels",
179+
requestArgs: map[string]any{
180+
"owner": "owner",
181+
"repo": "repo",
182+
"perPage": float64(2),
183+
},
184+
mockedClient: githubv4mock.NewMockedHTTPClient(listLabelsMatcher(2, (*githubv4.String)(nil), map[string]any{
185+
"hasNextPage": githubv4.Boolean(true),
186+
"hasPreviousPage": githubv4.Boolean(false),
187+
"startCursor": githubv4.String("c1"),
188+
"endCursor": githubv4.String("c2"),
189+
})),
190+
expectToolError: false,
191+
expectedPageInfo: MinimalPageInfo{HasNextPage: true, StartCursor: "c1", EndCursor: "c2"},
192+
},
193+
{
194+
name: "passes the after cursor to fetch the next page",
195+
requestArgs: map[string]any{
196+
"owner": "owner",
197+
"repo": "repo",
198+
"after": "c2",
199+
},
200+
mockedClient: githubv4mock.NewMockedHTTPClient(listLabelsMatcher(100, githubv4.String("c2"), map[string]any{
201+
"hasNextPage": githubv4.Boolean(false),
202+
"hasPreviousPage": githubv4.Boolean(true),
203+
"startCursor": githubv4.String("c3"),
204+
"endCursor": githubv4.String("c4"),
205+
})),
206+
expectToolError: false,
207+
expectedPageInfo: MinimalPageInfo{HasPreviousPage: true, StartCursor: "c3", EndCursor: "c4"},
208+
},
209+
{
210+
name: "rejects a page size above 100",
211+
requestArgs: map[string]any{
212+
"owner": "owner",
213+
"repo": "repo",
214+
"perPage": float64(101),
215+
},
216+
mockedClient: githubv4mock.NewMockedHTTPClient(),
217+
expectToolError: true,
218+
expectedToolErrMsg: "perPage value 101 exceeds maximum of 100",
211219
},
212220
}
213221

@@ -233,11 +241,61 @@ func TestListLabels(t *testing.T) {
233241
}
234242
} else {
235243
assert.False(t, result.IsError)
244+
var response ListLabelsOutput
245+
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
246+
assert.Len(t, response.Labels, 2)
247+
assert.Equal(t, 5, response.TotalCount)
248+
assert.Equal(t, tc.expectedPageInfo, response.PageInfo)
236249
}
237250
})
238251
}
239252
}
240253

254+
// listLabelsMatcher matches one list_label page request and answers with two labels and the given page info.
255+
func listLabelsMatcher(first int32, after any, pageInfo map[string]any) githubv4mock.Matcher {
256+
return githubv4mock.NewQueryMatcher(
257+
struct {
258+
Repository struct {
259+
Labels struct {
260+
Nodes []struct {
261+
ID githubv4.ID
262+
Name githubv4.String
263+
Color githubv4.String
264+
Description githubv4.String
265+
}
266+
TotalCount githubv4.Int
267+
PageInfo struct {
268+
HasNextPage githubv4.Boolean
269+
HasPreviousPage githubv4.Boolean
270+
StartCursor githubv4.String
271+
EndCursor githubv4.String
272+
}
273+
} `graphql:"labels(first: $first, after: $after, orderBy: {field: $orderByField, direction: $orderByDirection})"`
274+
} `graphql:"repository(owner: $owner, name: $repo)"`
275+
}{},
276+
map[string]any{
277+
"owner": githubv4.String("owner"),
278+
"repo": githubv4.String("repo"),
279+
"first": githubv4.Int(first),
280+
"after": after,
281+
"orderByField": labelOrderFieldIssueCount,
282+
"orderByDirection": githubv4.OrderDirectionDesc,
283+
},
284+
githubv4mock.DataResponse(map[string]any{
285+
"repository": map[string]any{
286+
"labels": map[string]any{
287+
"nodes": []any{
288+
map[string]any{"id": githubv4.ID("label-1"), "name": githubv4.String("bug"), "color": githubv4.String("d73a4a"), "description": githubv4.String("Something isn't working")},
289+
map[string]any{"id": githubv4.ID("label-2"), "name": githubv4.String("enhancement"), "color": githubv4.String("a2eeef"), "description": githubv4.String("New feature or request")},
290+
},
291+
"totalCount": githubv4.Int(5),
292+
"pageInfo": pageInfo,
293+
},
294+
},
295+
}),
296+
)
297+
}
298+
241299
func TestWriteLabel(t *testing.T) {
242300
t.Parallel()
243301

‎pkg/github/typed_governance_outputs.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ type MinimalLabel struct {
2020
}
2121

2222
type ListLabelsOutput struct {
23-
Labels []MinimalLabel `json:"labels"`
24-
TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."`
23+
Labels []MinimalLabel `json:"labels"`
24+
TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."`
25+
PageInfo MinimalPageInfo `json:"pageInfo"`
2526
}
2627

2728
type LabelWriteOutput struct {

‎pkg/github/typed_governance_outputs_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func assertGovernanceProjection(t *testing.T, tc governanceWireCase, output any)
7272
case "get_label":
7373
assert.JSONEq(t, `{"name":"bug","color":"ff0000","description":"desc"}`, mustMarshalJSON(t, output))
7474
case "list_label":
75-
assert.JSONEq(t, `{"labels":[{"name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`, mustMarshalJSON(t, output))
75+
assert.JSONEq(t, `{"labels":[{"name":"bug","color":"ff0000","description":"desc"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, mustMarshalJSON(t, output))
7676
case "label_write":
7777
assert.Equal(t, tc.args["method"], object["method"])
7878
assert.Equal(t, tc.text, object["message"])
@@ -236,7 +236,7 @@ func governanceWireCases(t *testing.T) []governanceWireCase {
236236
tr := translations.NullTranslationHelper
237237
return []governanceWireCase{
238238
{tool: GetLabel(tr), args: map[string]any{"owner": "o", "repo": "r", "name": "bug"}, text: `{"id":"L1","name":"bug","color":"ff0000","description":"desc"}`},
239-
{tool: ListLabels(tr), args: map[string]any{"owner": "o", "repo": "r"}, text: `{"labels":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`},
239+
{tool: ListLabels(tr), args: map[string]any{"owner": "o", "repo": "r"}, text: `{"labels":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`},
240240
{tool: LabelWrite(tr), args: map[string]any{"method": "create", "owner": "o", "repo": "r", "name": "feature", "color": "abcdef"}, text: "label 'feature' created successfully"},
241241
{tool: LabelWrite(tr), args: map[string]any{"method": "update", "owner": "o", "repo": "r", "name": "bug", "new_name": "defect"}, text: "label 'defect' updated successfully"},
242242
{tool: LabelWrite(tr), args: map[string]any{"method": "delete", "owner": "o", "repo": "r", "name": "bug"}, text: "label 'bug' deleted successfully"},

0 commit comments

Comments
 (0)