Skip to content

Commit 7311be6

Browse files
A-GeorgiouCopilot
andcommitted
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
1 parent 796afb7 commit 7311be6

5 files changed

Lines changed: 131 additions & 20 deletions

File tree

‎pkg/github/__toolsnaps__/list_label.snap‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
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 all labels from a repository, ordered by issue count (descending) so the most-used labels are returned first. has_more is true when the repository has more labels than could be returned; use get_label to check a specific label by name.",
88
"inputSchema": {
99
"properties": {
1010
"owner": {
@@ -26,6 +26,10 @@
2626
"outputSchema": {
2727
"additionalProperties": false,
2828
"properties": {
29+
"has_more": {
30+
"description": "Whether the repository has more labels than were returned.",
31+
"type": "boolean"
32+
},
2933
"labels": {
3034
"items": {
3135
"additionalProperties": false,
@@ -60,7 +64,8 @@
6064
},
6165
"required": [
6266
"labels",
63-
"totalCount"
67+
"totalCount",
68+
"has_more"
6469
],
6570
"type": "object"
6671
}

‎pkg/github/labels.go‎

Lines changed: 34 additions & 15 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+
// listLabelsMaxPages bounds list_label to 1,000 labels so one call cannot page indefinitely.
136+
const listLabelsMaxPages = 10
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,7 +145,7 @@ 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 all labels from a repository, ordered by issue count (descending) so the most-used labels are returned first. has_more is true when the repository has more labels than could be returned; use get_label to check a specific label by name."),
146149
Annotations: &mcp.ToolAnnotations{
147150
Title: t("TOOL_LIST_LABEL_TITLE", "List labels from a repository"),
148151
ReadOnlyHint: true,
@@ -189,35 +192,51 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
189192
Description githubv4.String
190193
}
191194
TotalCount githubv4.Int
192-
} `graphql:"labels(first: 100, orderBy: {field: $orderByField, direction: $orderByDirection})"`
195+
PageInfo struct {
196+
HasNextPage githubv4.Boolean
197+
EndCursor githubv4.String
198+
}
199+
} `graphql:"labels(first: 100, after: $cursor, orderBy: {field: $orderByField, direction: $orderByDirection})"`
193200
} `graphql:"repository(owner: $owner, name: $repo)"`
194201
}
195202

196203
vars := map[string]any{
197-
"owner": githubv4.String(owner),
198-
"repo": githubv4.String(repo),
204+
"owner": githubv4.String(owner),
205+
"repo": githubv4.String(repo),
206+
"cursor": (*githubv4.String)(nil),
199207
// Order labels by issue count (descending) so the most-used labels are returned first.
200208
"orderByField": labelOrderFieldIssueCount,
201209
"orderByDirection": githubv4.OrderDirectionDesc,
202210
}
203211

204-
if err := client.Query(ctx, &query, vars); err != nil {
205-
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
206-
}
207-
208-
labels := make([]map[string]any, len(query.Repository.Labels.Nodes))
209-
for i, labelNode := range query.Repository.Labels.Nodes {
210-
labels[i] = map[string]any{
211-
"id": fmt.Sprintf("%v", labelNode.ID),
212-
"name": string(labelNode.Name),
213-
"color": string(labelNode.Color),
214-
"description": string(labelNode.Description),
212+
labels := make([]map[string]any, 0)
213+
hasMore := false
214+
for page := 1; ; page++ {
215+
if err := client.Query(ctx, &query, vars); err != nil {
216+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
217+
}
218+
for _, labelNode := range query.Repository.Labels.Nodes {
219+
labels = append(labels, map[string]any{
220+
"id": fmt.Sprintf("%v", labelNode.ID),
221+
"name": string(labelNode.Name),
222+
"color": string(labelNode.Color),
223+
"description": string(labelNode.Description),
224+
})
225+
}
226+
if !query.Repository.Labels.PageInfo.HasNextPage {
227+
break
228+
}
229+
if page >= listLabelsMaxPages {
230+
hasMore = true
231+
break
215232
}
233+
vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor)
216234
}
217235

218236
response := map[string]any{
219237
"labels": labels,
220238
"totalCount": int(query.Repository.Labels.TotalCount),
239+
"has_more": hasMore,
221240
}
222241

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

‎pkg/github/labels_test.go‎

Lines changed: 87 additions & 1 deletion
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,6 +157,9 @@ func TestListLabels(t *testing.T) {
156157
mockedClient *http.Client
157158
expectToolError bool
158159
expectedToolErrMsg string
160+
expectedNames []string
161+
expectedCount int
162+
expectHasMore bool
159163
}{
160164
{
161165
name: "successful repository labels listing",
@@ -175,12 +179,17 @@ func TestListLabels(t *testing.T) {
175179
Description githubv4.String
176180
}
177181
TotalCount githubv4.Int
178-
} `graphql:"labels(first: 100, orderBy: {field: $orderByField, direction: $orderByDirection})"`
182+
PageInfo struct {
183+
HasNextPage githubv4.Boolean
184+
EndCursor githubv4.String
185+
}
186+
} `graphql:"labels(first: 100, after: $cursor, orderBy: {field: $orderByField, direction: $orderByDirection})"`
179187
} `graphql:"repository(owner: $owner, name: $repo)"`
180188
}{},
181189
map[string]any{
182190
"owner": githubv4.String("owner"),
183191
"repo": githubv4.String("repo"),
192+
"cursor": (*githubv4.String)(nil),
184193
"orderByField": labelOrderFieldIssueCount,
185194
"orderByDirection": githubv4.OrderDirectionDesc,
186195
},
@@ -202,12 +211,38 @@ func TestListLabels(t *testing.T) {
202211
},
203212
},
204213
"totalCount": githubv4.Int(2),
214+
"pageInfo": map[string]any{
215+
"hasNextPage": githubv4.Boolean(false),
216+
"endCursor": githubv4.String(""),
217+
},
205218
},
206219
},
207220
}),
208221
),
209222
),
210223
expectToolError: false,
224+
expectedNames: []string{"bug", "enhancement"},
225+
},
226+
{
227+
name: "follows cursors so the least-used labels are not dropped",
228+
requestArgs: map[string]any{
229+
"owner": "owner",
230+
"repo": "repo",
231+
},
232+
mockedClient: twoPageLabelsClient(t),
233+
expectToolError: false,
234+
expectedNames: []string{"bug", "rarely-used"},
235+
},
236+
{
237+
name: "stops at the page cap and sets has_more",
238+
requestArgs: map[string]any{
239+
"owner": "owner",
240+
"repo": "repo",
241+
},
242+
mockedClient: alwaysHasNextPageLabelsClient(t),
243+
expectToolError: false,
244+
expectedCount: listLabelsMaxPages,
245+
expectHasMore: true,
211246
},
212247
}
213248

@@ -233,11 +268,62 @@ func TestListLabels(t *testing.T) {
233268
}
234269
} else {
235270
assert.False(t, result.IsError)
271+
var response struct {
272+
Labels []struct {
273+
Name string `json:"name"`
274+
} `json:"labels"`
275+
HasMore bool `json:"has_more"`
276+
}
277+
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
278+
names := make([]string, len(response.Labels))
279+
for i, label := range response.Labels {
280+
names[i] = label.Name
281+
}
282+
if tc.expectedNames != nil {
283+
assert.Equal(t, tc.expectedNames, names)
284+
}
285+
if tc.expectedCount != 0 {
286+
assert.Len(t, names, tc.expectedCount)
287+
}
288+
assert.Equal(t, tc.expectHasMore, response.HasMore)
236289
}
237290
})
238291
}
239292
}
240293

294+
// twoPageLabelsClient serves a first page that reports another page, then a second page reached only through its cursor.
295+
func twoPageLabelsClient(t *testing.T) *http.Client {
296+
t.Helper()
297+
mux := http.NewServeMux()
298+
mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
299+
var req struct {
300+
Variables map[string]any `json:"variables"`
301+
}
302+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
303+
http.Error(w, err.Error(), http.StatusBadRequest)
304+
return
305+
}
306+
name, hasNext := "bug", true
307+
if req.Variables["cursor"] == "page-2" {
308+
name, hasNext = "rarely-used", false
309+
}
310+
resp := map[string]any{
311+
"data": map[string]any{
312+
"repository": map[string]any{
313+
"labels": map[string]any{
314+
"nodes": []any{map[string]any{"id": name, "name": name, "color": "ededed", "description": ""}},
315+
"totalCount": 2,
316+
"pageInfo": map[string]any{"hasNextPage": hasNext, "endCursor": "page-2"},
317+
},
318+
},
319+
},
320+
}
321+
w.Header().Set("Content-Type", "application/json")
322+
_ = json.NewEncoder(w).Encode(resp)
323+
})
324+
return &http.Client{Transport: recorderTransport{handler: mux}}
325+
}
326+
241327
func TestWriteLabel(t *testing.T) {
242328
t.Parallel()
243329

‎pkg/github/typed_governance_outputs.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type MinimalLabel struct {
2222
type ListLabelsOutput struct {
2323
Labels []MinimalLabel `json:"labels"`
2424
TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."`
25+
HasMore bool `json:"has_more" jsonschema:"Whether the repository has more labels than were returned."`
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,"has_more":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,"has_more":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)