Skip to content

Commit 209c966

Browse files
committed
Bound user-list item-inclusive responses
Expose standard cursor pagination on list_user_lists and return one bounded page of lists per call. When items are included, return at most the first 100 repositories per list together with itemsPageInfo instead of eagerly draining every nested connection. Update focused tests, tool snapshot, and generated documentation.
1 parent 5d35506 commit 209c966

4 files changed

Lines changed: 105 additions & 144 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,7 +1507,9 @@ The following sets of tools are available:
15071507

15081508
- **list_user_lists** - List star lists
15091509
- **OAuth Challenge Scopes**: `read:user`, `repo`
1510+
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
15101511
- `include_items`: Whether to include the repositories in each list. (boolean, optional)
1512+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
15111513

15121514
- **remove_repository_from_list** - Remove repository from star list
15131515
- **OAuth Challenge Scopes**: `user`, `repo`

pkg/github/__toolsnaps__/list_user_lists.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,19 @@
77
"description": "List the authenticated user's star lists (UserLists), optionally including the repositories in each list.",
88
"inputSchema": {
99
"properties": {
10+
"after": {
11+
"description": "Cursor for pagination. Use the cursor from the previous response.",
12+
"type": "string"
13+
},
1014
"include_items": {
1115
"description": "Whether to include the repositories in each list.",
1216
"type": "boolean"
17+
},
18+
"perPage": {
19+
"description": "Results per page for pagination (min 1, max 100)",
20+
"maximum": 100,
21+
"minimum": 1,
22+
"type": "number"
1323
}
1424
},
1525
"type": "object"

pkg/github/user_lists.go

Lines changed: 72 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,25 @@ import (
1818

1919
// userList represents a GitHub star list (UserList) surfaced through the tools.
2020
type userList struct {
21-
ID githubv4.ID `json:"id"`
22-
Name string `json:"name"`
23-
Description string `json:"description,omitempty"`
24-
IsPrivate bool `json:"is_private"`
25-
Items []userListItem `json:"items"`
21+
ID githubv4.ID `json:"id"`
22+
Name string `json:"name"`
23+
Description string `json:"description,omitempty"`
24+
IsPrivate bool `json:"is_private"`
25+
Items []userListItem `json:"items"`
26+
ItemsPageInfo *userListPageInfo `json:"itemsPageInfo,omitempty"`
2627
}
2728

2829
type userListItem struct {
2930
Repository string `json:"repository"`
3031
}
3132

33+
type userListPageInfo struct {
34+
HasNextPage bool `json:"hasNextPage"`
35+
HasPreviousPage bool `json:"hasPreviousPage"`
36+
StartCursor string `json:"startCursor,omitempty"`
37+
EndCursor string `json:"endCursor,omitempty"`
38+
}
39+
3240
type userListLookupQuery struct {
3341
Viewer struct {
3442
Lists struct {
@@ -53,12 +61,9 @@ type userListPageQuery struct {
5361
Description githubv4.String
5462
IsPrivate githubv4.Boolean
5563
}
56-
PageInfo struct {
57-
HasNextPage bool
58-
EndCursor string
59-
}
64+
PageInfo userListPageInfo
6065
TotalCount githubv4.Int
61-
} `graphql:"lists(first: 100, after: $after)"`
66+
} `graphql:"lists(first: $first, after: $after)"`
6267
}
6368
}
6469

@@ -76,18 +81,12 @@ type userListPageWithItemsQuery struct {
7681
NameWithOwner githubv4.String
7782
} `graphql:"... on Repository"`
7883
}
79-
PageInfo struct {
80-
HasNextPage bool
81-
EndCursor string
82-
}
84+
PageInfo userListPageInfo
8385
} `graphql:"items(first: 100)"`
8486
}
85-
PageInfo struct {
86-
HasNextPage bool
87-
EndCursor string
88-
}
87+
PageInfo userListPageInfo
8988
TotalCount githubv4.Int
90-
} `graphql:"lists(first: 100, after: $after)"`
89+
} `graphql:"lists(first: $first, after: $after)"`
9190
}
9291
}
9392

@@ -115,112 +114,49 @@ func getUserListID(ctx context.Context, client *githubv4.Client, name string) (g
115114
return "", fmt.Errorf("list '%s' not found", name)
116115
}
117116

118-
// listUserLists returns the authenticated user's star lists, optionally
119-
// including the repositories each list contains.
120-
func listUserLists(ctx context.Context, client *githubv4.Client, includeItems bool) ([]userList, int, error) {
121-
lists := make([]userList, 0)
122-
totalCount := 0
123-
var after *githubv4.String
124-
for {
125-
if includeItems {
126-
var query userListPageWithItemsQuery
127-
vars := map[string]any{"after": after}
128-
if err := client.Query(ctx, &query, vars); err != nil {
129-
return nil, 0, err
130-
}
131-
totalCount = int(query.Viewer.Lists.TotalCount)
132-
for _, node := range query.Viewer.Lists.Nodes {
133-
items := make([]userListItem, 0, len(node.Items.Nodes))
134-
for _, item := range node.Items.Nodes {
135-
items = append(items, userListItem{Repository: string(item.Repository.NameWithOwner)})
136-
}
137-
if node.Items.PageInfo.HasNextPage {
138-
cursor := githubv4.String(node.Items.PageInfo.EndCursor)
139-
remaining, err := listUserListItems(ctx, client, node.ID, &cursor)
140-
if err != nil {
141-
return nil, 0, err
142-
}
143-
items = append(items, remaining...)
144-
}
145-
lists = append(lists, userList{
146-
ID: node.ID,
147-
Name: string(node.Name),
148-
Description: string(node.Description),
149-
IsPrivate: bool(node.IsPrivate),
150-
Items: items,
151-
})
152-
}
153-
if !query.Viewer.Lists.PageInfo.HasNextPage {
154-
break
155-
}
156-
cursor := githubv4.String(query.Viewer.Lists.PageInfo.EndCursor)
157-
after = &cursor
158-
continue
159-
}
160-
161-
var query userListPageQuery
162-
vars := map[string]any{"after": after}
117+
// listUserLists returns one bounded page of the authenticated user's star
118+
// lists. When includeItems is true, each list includes at most its first 100
119+
// repositories plus cursor metadata indicating whether more items exist.
120+
func listUserLists(ctx context.Context, client *githubv4.Client, includeItems bool, first githubv4.Int, after *githubv4.String) ([]userList, int, userListPageInfo, error) {
121+
vars := map[string]any{"first": first, "after": after}
122+
if includeItems {
123+
var query userListPageWithItemsQuery
163124
if err := client.Query(ctx, &query, vars); err != nil {
164-
return nil, 0, err
125+
return nil, 0, userListPageInfo{}, err
165126
}
166-
totalCount = int(query.Viewer.Lists.TotalCount)
127+
lists := make([]userList, 0, len(query.Viewer.Lists.Nodes))
167128
for _, node := range query.Viewer.Lists.Nodes {
168-
list := userList{
169-
ID: node.ID,
170-
Name: string(node.Name),
171-
Description: string(node.Description),
172-
IsPrivate: bool(node.IsPrivate),
173-
}
174-
lists = append(lists, list)
175-
}
176-
if !query.Viewer.Lists.PageInfo.HasNextPage {
177-
break
129+
items := make([]userListItem, 0, len(node.Items.Nodes))
130+
for _, item := range node.Items.Nodes {
131+
items = append(items, userListItem{Repository: string(item.Repository.NameWithOwner)})
132+
}
133+
itemsPageInfo := node.Items.PageInfo
134+
lists = append(lists, userList{
135+
ID: node.ID,
136+
Name: string(node.Name),
137+
Description: string(node.Description),
138+
IsPrivate: bool(node.IsPrivate),
139+
Items: items,
140+
ItemsPageInfo: &itemsPageInfo,
141+
})
178142
}
179-
cursor := githubv4.String(query.Viewer.Lists.PageInfo.EndCursor)
180-
after = &cursor
143+
return lists, int(query.Viewer.Lists.TotalCount), query.Viewer.Lists.PageInfo, nil
181144
}
182-
return lists, totalCount, nil
183-
}
184145

185-
// listUserListItems returns the repositories held by a single list, following
186-
// the items connection's cursor until every page has been consumed.
187-
func listUserListItems(ctx context.Context, client *githubv4.Client, listID githubv4.ID, after *githubv4.String) ([]userListItem, error) {
188-
items := make([]userListItem, 0)
189-
for {
190-
var query struct {
191-
Node struct {
192-
UserList struct {
193-
Items struct {
194-
Nodes []struct {
195-
Repository struct {
196-
NameWithOwner githubv4.String
197-
} `graphql:"... on Repository"`
198-
}
199-
PageInfo struct {
200-
HasNextPage bool
201-
EndCursor string
202-
}
203-
} `graphql:"items(first: 100, after: $after)"`
204-
} `graphql:"... on UserList"`
205-
} `graphql:"node(id: $id)"`
206-
}
207-
vars := map[string]any{
208-
"id": listID,
209-
"after": after,
210-
}
211-
if err := client.Query(ctx, &query, vars); err != nil {
212-
return nil, err
213-
}
214-
for _, node := range query.Node.UserList.Items.Nodes {
215-
items = append(items, userListItem{Repository: string(node.Repository.NameWithOwner)})
216-
}
217-
if !query.Node.UserList.Items.PageInfo.HasNextPage {
218-
break
219-
}
220-
cursor := githubv4.String(query.Node.UserList.Items.PageInfo.EndCursor)
221-
after = &cursor
146+
var query userListPageQuery
147+
if err := client.Query(ctx, &query, vars); err != nil {
148+
return nil, 0, userListPageInfo{}, err
222149
}
223-
return items, nil
150+
lists := make([]userList, 0, len(query.Viewer.Lists.Nodes))
151+
for _, node := range query.Viewer.Lists.Nodes {
152+
lists = append(lists, userList{
153+
ID: node.ID,
154+
Name: string(node.Name),
155+
Description: string(node.Description),
156+
IsPrivate: bool(node.IsPrivate),
157+
})
158+
}
159+
return lists, int(query.Viewer.Lists.TotalCount), query.Viewer.Lists.PageInfo, nil
224160
}
225161

226162
// repoInList reports whether the repository identified by repoID belongs to the
@@ -494,35 +430,50 @@ func ListUserLists(t translations.TranslationHelperFunc) inventory.ServerTool {
494430
Title: t("TOOL_LIST_USER_LISTS_USER_TITLE", "List star lists"),
495431
ReadOnlyHint: true,
496432
},
497-
InputSchema: &jsonschema.Schema{
433+
InputSchema: WithCursorPagination(&jsonschema.Schema{
498434
Type: "object",
499435
Properties: map[string]*jsonschema.Schema{
500436
"include_items": {
501437
Type: "boolean",
502438
Description: "Whether to include the repositories in each list.",
503439
},
504440
},
505-
},
441+
}),
506442
},
507443
userListReadScopeAccess(),
508444
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
509445
includeItems, err := OptionalParam[bool](args, "include_items")
510446
if err != nil {
511447
return utils.NewToolResultError(err.Error()), nil, nil
512448
}
449+
pagination, err := OptionalCursorPaginationParams(args)
450+
if err != nil {
451+
return utils.NewToolResultError(err.Error()), nil, nil
452+
}
453+
paginationParams, err := pagination.ToGraphQLParams()
454+
if err != nil {
455+
return utils.NewToolResultError(err.Error()), nil, nil
456+
}
457+
first := githubv4.Int(*paginationParams.First)
458+
var after *githubv4.String
459+
if paginationParams.After != nil {
460+
cursor := githubv4.String(*paginationParams.After)
461+
after = &cursor
462+
}
513463

514464
client, err := deps.GetGQLClient(ctx)
515465
if err != nil {
516466
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
517467
}
518468

519-
lists, totalCount, err := listUserLists(ctx, client, includeItems)
469+
lists, totalCount, pageInfo, err := listUserLists(ctx, client, includeItems, first, after)
520470
if err != nil {
521471
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list user lists", err), nil, nil
522472
}
523473

524474
response := map[string]any{
525475
"lists": lists,
476+
"pageInfo": pageInfo,
526477
"totalCount": totalCount,
527478
}
528479
out, err := json.Marshal(response)

0 commit comments

Comments
 (0)