diff --git a/README.md b/README.md index 87965cb09d..a7ffdbc6c7 100644 --- a/README.md +++ b/README.md @@ -1481,6 +1481,22 @@ The following sets of tools are available: star Stargazers +- **add_repository_to_list** - Add repository to star list + - **OAuth Challenge Scopes**: `user`, `repo` + - `list_name`: The name of the star list to add the repository to. (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **create_user_list** - Create star list + - **OAuth Challenge Scopes**: `user` + - `description`: A description of the list. (string, optional) + - `is_private`: Whether the list is private. (boolean, optional) + - `name`: The name of the new list. (string, required) + +- **delete_user_list** - Delete star list + - **OAuth Challenge Scopes**: `user` + - `name`: The name of the list to delete. (string, required) + - **list_starred_repositories** - List starred repositories - **OAuth Challenge Scopes**: `repo` - `direction`: The direction to sort the results by. (string, optional) @@ -1489,6 +1505,24 @@ The following sets of tools are available: - `sort`: How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to). (string, optional) - `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional) +- **list_user_list_items** - List star list items + - **OAuth Challenge Scopes**: `read:user`, `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `name`: The name of the star list whose repositories should be listed. (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + +- **list_user_lists** - List star lists + - **OAuth Challenge Scopes**: `read:user`, `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `include_items`: Whether to include up to 100 repositories and item cursor metadata for each returned list. (boolean, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + +- **remove_repository_from_list** - Remove repository from star list + - **OAuth Challenge Scopes**: `user`, `repo` + - `list_name`: The name of the star list to remove the repository from. (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **star_repository** - Star repository - **OAuth Challenge Scopes**: `repo` - `owner`: Repository owner (string, required) @@ -1499,6 +1533,13 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) +- **update_user_list** - Update star list + - **OAuth Challenge Scopes**: `user` + - `description`: The new description for the list. (string, optional) + - `is_private`: Whether the list is private. (boolean, optional) + - `name`: The current name of the list to update. (string, required) + - `new_name`: The new name for the list. (string, optional) +
diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 112f653a37..1c02aa93a5 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -1997,3 +1997,171 @@ func TestPullRequestReviewDeletion(t *testing.T) { require.NoError(t, err, "expected to unmarshal text content successfully") require.Len(t, noReviews, 0, "expected to find no reviews") } + +// TestUserLists exercises the star list (UserList) tools end-to-end. It creates +// a private list, renames it, adds a repository, verifies membership, removes the +// repository, and deletes the list. Because list management is a global +// (per-account) mutation with no repository boundary, the test creates its own +// uniquely named list and cleans it up in every exit path. +func TestUserLists(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient(t) + ctx := context.Background() + + listName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + renamedList := listName + "-renamed" + + // currentListName tracks the list's current name so cleanup deletes the + // right one even if the rename below fails after creation. It is only + // advanced once the rename succeeds. + currentListName := listName + t.Cleanup(func() { + t.Logf("Cleaning up list %q...", currentListName) + resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "delete_user_list", + Arguments: map[string]any{"name": currentListName}, + }) + if err == nil && resp.IsError { + t.Logf("Cleanup: failed to delete list %q: %+v", currentListName, resp) + } + }) + + // Create a list. + t.Logf("Creating list %q...", listName) + resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "create_user_list", + Arguments: map[string]any{ + "name": listName, + "description": "e2e test list", + "is_private": true, + }, + }) + require.NoError(t, err, "expected to call 'create_user_list' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Rename the list. + t.Logf("Renaming list %q -> %q...", listName, renamedList) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "update_user_list", + Arguments: map[string]any{ + "name": listName, + "new_name": renamedList, + }, + }) + require.NoError(t, err, "expected to call 'update_user_list' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + currentListName = renamedList + + // Use the user's own account to find a repository to add. We create one so + // the test is self-contained and does not depend on any pre-existing repo. + t.Log("Getting current user...") + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{Name: "get_me"}) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok := resp.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + var me struct { + Login string `json:"login"` + } + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &me), "expected to unmarshal text content successfully") + currentOwner := me.Login + + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "create_repository", + Arguments: map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + }, + }) + require.NoError(t, err, "expected to call 'create_repository' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + t.Cleanup(func() { + ghClient := getRESTClient(t) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Add the repository to the list. + t.Logf("Adding %s/%s to list %q...", currentOwner, repoName, renamedList) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "add_repository_to_list", + Arguments: map[string]any{ + "owner": currentOwner, + "repo": repoName, + "list_name": renamedList, + }, + }) + require.NoError(t, err, "expected to call 'add_repository_to_list' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Verify membership by listing lists with items and finding ours. + t.Log("Verifying membership via 'list_user_lists'...") + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "list_user_lists", + Arguments: map[string]any{"include_items": true}, + }) + require.NoError(t, err, "expected to call 'list_user_lists' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + var listing struct { + Lists []struct { + Name string `json:"name"` + Items []struct { + Repository string `json:"repository"` + } `json:"items"` + } `json:"lists"` + } + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &listing), "expected to unmarshal text content successfully") + + var found *struct { + Name string `json:"name"` + Items []struct { + Repository string `json:"repository"` + } `json:"items"` + } + for i := range listing.Lists { + if listing.Lists[i].Name == renamedList { + found = &listing.Lists[i] + break + } + } + require.NotNil(t, found, "expected to find list %q in listing", renamedList) + var containsRepo bool + for _, item := range found.Items { + if item.Repository == currentOwner+"/"+repoName { + containsRepo = true + break + } + } + require.True(t, containsRepo, "expected list %q to contain %s/%s", renamedList, currentOwner, repoName) + + // Remove the repository from the list. + t.Logf("Removing %s/%s from list %q...", currentOwner, repoName, renamedList) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "remove_repository_from_list", + Arguments: map[string]any{ + "owner": currentOwner, + "repo": repoName, + "list_name": renamedList, + }, + }) + require.NoError(t, err, "expected to call 'remove_repository_from_list' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Delete the list (cleanup also deletes it, but this asserts the tool works). + t.Logf("Deleting list %q...", renamedList) + resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "delete_user_list", + Arguments: map[string]any{"name": renamedList}, + }) + require.NoError(t, err, "expected to call 'delete_user_list' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) +} diff --git a/pkg/github/__toolsnaps__/add_repository_to_list.snap b/pkg/github/__toolsnaps__/add_repository_to_list.snap new file mode 100644 index 0000000000..323e0f8be0 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_repository_to_list.snap @@ -0,0 +1,32 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "readOnlyHint": false, + "title": "Add repository to star list" + }, + "description": "Add a repository to a star list (UserList). List membership is independent of star state.", + "inputSchema": { + "properties": { + "list_name": { + "description": "The name of the star list to add the repository to.", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "list_name" + ], + "type": "object" + }, + "name": "add_repository_to_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_user_list.snap b/pkg/github/__toolsnaps__/create_user_list.snap new file mode 100644 index 0000000000..e7bfd91fb8 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_user_list.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "readOnlyHint": false, + "title": "Create star list" + }, + "description": "Create a new star list (UserList) for the authenticated user.", + "inputSchema": { + "properties": { + "description": { + "description": "A description of the list.", + "type": "string" + }, + "is_private": { + "description": "Whether the list is private.", + "type": "boolean" + }, + "name": { + "description": "The name of the new list.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "create_user_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_user_list.snap b/pkg/github/__toolsnaps__/delete_user_list.snap new file mode 100644 index 0000000000..529e8b3b23 --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_user_list.snap @@ -0,0 +1,22 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, + "title": "Delete star list" + }, + "description": "Delete a star list (UserList) owned by the authenticated user.", + "inputSchema": { + "properties": { + "name": { + "description": "The name of the list to delete.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "delete_user_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_user_list_items.snap b/pkg/github/__toolsnaps__/list_user_list_items.snap new file mode 100644 index 0000000000..69d12ade25 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_user_list_items.snap @@ -0,0 +1,31 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List star list items" + }, + "description": "List a page of repositories in one star list (UserList).", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "name": { + "description": "The name of the star list whose repositories should be listed.", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "list_user_list_items" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_user_lists.snap b/pkg/github/__toolsnaps__/list_user_lists.snap new file mode 100644 index 0000000000..34fd17eef7 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_user_lists.snap @@ -0,0 +1,28 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List star lists" + }, + "description": "List a page of the authenticated user's star lists (UserLists), optionally including the first page of repositories in each list.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "include_items": { + "description": "Whether to include up to 100 repositories and item cursor metadata for each returned list.", + "type": "boolean" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + } + }, + "type": "object" + }, + "name": "list_user_lists" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_repository_from_list.snap b/pkg/github/__toolsnaps__/remove_repository_from_list.snap new file mode 100644 index 0000000000..33c67b3469 --- /dev/null +++ b/pkg/github/__toolsnaps__/remove_repository_from_list.snap @@ -0,0 +1,32 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "readOnlyHint": false, + "title": "Remove repository from star list" + }, + "description": "Remove a repository from a star list (UserList). List membership is independent of star state.", + "inputSchema": { + "properties": { + "list_name": { + "description": "The name of the star list to remove the repository from.", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "list_name" + ], + "type": "object" + }, + "name": "remove_repository_from_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_user_list.snap b/pkg/github/__toolsnaps__/update_user_list.snap new file mode 100644 index 0000000000..17978b9a51 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_user_list.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "readOnlyHint": false, + "title": "Update star list" + }, + "description": "Update an existing star list (UserList): rename it, change its description, or change its privacy.", + "inputSchema": { + "properties": { + "description": { + "description": "The new description for the list.", + "type": "string" + }, + "is_private": { + "description": "Whether the list is private.", + "type": "boolean" + }, + "name": { + "description": "The current name of the list to update.", + "type": "string" + }, + "new_name": { + "description": "The new name for the list.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "update_user_list" +} \ No newline at end of file diff --git a/pkg/github/tool_scopes.go b/pkg/github/tool_scopes.go index 4482a6e243..0790fdab3e 100644 --- a/pkg/github/tool_scopes.go +++ b/pkg/github/tool_scopes.go @@ -69,3 +69,19 @@ func uiGetScopeAccess() inventory.ScopeAccess { }, ) } + +func userListReadScopeAccess() inventory.ScopeAccess { + return scopes.DynamicChallenge( + []scopes.Scope{scopes.ReadUser, scopes.Repo}, + func(activeScopes []string) bool { + return scopes.HasAll(activeScopes, scopes.ReadUser) + }, + func(arguments map[string]any, activeScopes []string) []string { + includeItems, ok := arguments["include_items"].(bool) + if ok && includeItems { + return scopes.ChallengeAll(activeScopes, scopes.ReadUser, scopes.Repo) + } + return scopes.ChallengeAll(activeScopes, scopes.ReadUser) + }, + ) +} diff --git a/pkg/github/tool_scopes_test.go b/pkg/github/tool_scopes_test.go index 6854237f56..04928555d5 100644 --- a/pkg/github/tool_scopes_test.go +++ b/pkg/github/tool_scopes_test.go @@ -109,6 +109,7 @@ func TestDynamicToolScopeMetadataIsExhaustive(t *testing.T) { {tool: ListIssueFields(translations.NullTranslationHelper), maxScopes: []string{"repo", "read:org"}}, {tool: ListIssueTypes(translations.NullTranslationHelper), maxScopes: []string{"repo", "read:org"}}, {tool: UIGet(translations.NullTranslationHelper), maxScopes: []string{"repo", "read:org"}}, + {tool: ListUserLists(translations.NullTranslationHelper), maxScopes: []string{"read:user", "repo"}}, } for _, tt := range tests { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ca46deadd2..c2f8798b84 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -241,6 +241,13 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + ListUserLists(t), + ListUserListItems(t), + CreateUserList(t), + UpdateUserList(t), + DeleteUserList(t), + AddRepositoryToList(t), + RemoveRepositoryFromList(t), ListRepositoryCollaborators(t), // Git tools diff --git a/pkg/github/user_lists.go b/pkg/github/user_lists.go new file mode 100644 index 0000000000..a9c1fe5b8f --- /dev/null +++ b/pkg/github/user_lists.go @@ -0,0 +1,900 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// userList represents a GitHub star list (UserList) surfaced through the tools. +type userList struct { + ID githubv4.ID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsPrivate bool `json:"is_private"` + Items []userListItem `json:"items"` + ItemsPageInfo *userListPageInfo `json:"itemsPageInfo,omitempty"` +} + +type userListItem struct { + Repository string `json:"repository"` +} + +type userListPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor,omitempty"` + EndCursor string `json:"endCursor,omitempty"` +} + +type userListLookupQuery struct { + Viewer struct { + Lists struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"lists(first: 100, after: $after)"` + } +} + +type userListPageQuery struct { + Viewer struct { + Lists struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + IsPrivate githubv4.Boolean + } + PageInfo userListPageInfo + TotalCount githubv4.Int + } `graphql:"lists(first: $first, after: $after)"` + } +} + +type userListPageWithItemsQuery struct { + Viewer struct { + Lists struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + IsPrivate githubv4.Boolean + Items struct { + Nodes []struct { + Repository struct { + NameWithOwner githubv4.String + } `graphql:"... on Repository"` + } + PageInfo userListPageInfo + } `graphql:"items(first: 100)"` + } + PageInfo userListPageInfo + TotalCount githubv4.Int + } `graphql:"lists(first: $first, after: $after)"` + } +} + +type userListItemsPageQuery struct { + Node struct { + UserList struct { + Items struct { + Nodes []struct { + Repository struct { + NameWithOwner githubv4.String + } `graphql:"... on Repository"` + } + PageInfo userListPageInfo + TotalCount githubv4.Int + } `graphql:"items(first: $first, after: $after)"` + } `graphql:"... on UserList"` + } `graphql:"node(id: $id)"` +} + +// getUserListID resolves the authenticated user's list with the given name to +// its node ID. It returns an error when no list matches the name. +func getUserListID(ctx context.Context, client *githubv4.Client, name string) (githubv4.ID, error) { + var after *githubv4.String + for { + var query userListLookupQuery + vars := map[string]any{"after": after} + if err := client.Query(ctx, &query, vars); err != nil { + return "", err + } + for _, node := range query.Viewer.Lists.Nodes { + if string(node.Name) == name { + return node.ID, nil + } + } + if !query.Viewer.Lists.PageInfo.HasNextPage { + break + } + cursor := githubv4.String(query.Viewer.Lists.PageInfo.EndCursor) + after = &cursor + } + return "", fmt.Errorf("list '%s' not found", name) +} + +// listUserLists returns one bounded page of the authenticated user's star +// lists. When includeItems is true, each list includes at most its first 100 +// repositories plus cursor metadata indicating whether more items exist. +func listUserLists(ctx context.Context, client *githubv4.Client, includeItems bool, first githubv4.Int, after *githubv4.String) ([]userList, int, userListPageInfo, error) { + vars := map[string]any{"first": first, "after": after} + if includeItems { + var query userListPageWithItemsQuery + if err := client.Query(ctx, &query, vars); err != nil { + return nil, 0, userListPageInfo{}, err + } + lists := make([]userList, 0, len(query.Viewer.Lists.Nodes)) + for _, node := range query.Viewer.Lists.Nodes { + items := make([]userListItem, 0, len(node.Items.Nodes)) + for _, item := range node.Items.Nodes { + items = append(items, userListItem{Repository: string(item.Repository.NameWithOwner)}) + } + itemsPageInfo := node.Items.PageInfo + lists = append(lists, userList{ + ID: node.ID, + Name: string(node.Name), + Description: string(node.Description), + IsPrivate: bool(node.IsPrivate), + Items: items, + ItemsPageInfo: &itemsPageInfo, + }) + } + return lists, int(query.Viewer.Lists.TotalCount), query.Viewer.Lists.PageInfo, nil + } + + var query userListPageQuery + if err := client.Query(ctx, &query, vars); err != nil { + return nil, 0, userListPageInfo{}, err + } + lists := make([]userList, 0, len(query.Viewer.Lists.Nodes)) + for _, node := range query.Viewer.Lists.Nodes { + lists = append(lists, userList{ + ID: node.ID, + Name: string(node.Name), + Description: string(node.Description), + IsPrivate: bool(node.IsPrivate), + }) + } + return lists, int(query.Viewer.Lists.TotalCount), query.Viewer.Lists.PageInfo, nil +} + +func listUserListItemsPage(ctx context.Context, client *githubv4.Client, listID githubv4.ID, first githubv4.Int, after *githubv4.String) ([]userListItem, int, userListPageInfo, error) { + var query userListItemsPageQuery + var afterVariable any = (*githubv4.String)(nil) + if after != nil { + afterVariable = *after + } + vars := map[string]any{"id": listID, "first": first, "after": afterVariable} + if err := client.Query(ctx, &query, vars); err != nil { + return nil, 0, userListPageInfo{}, err + } + items := make([]userListItem, 0, len(query.Node.UserList.Items.Nodes)) + for _, node := range query.Node.UserList.Items.Nodes { + items = append(items, userListItem{Repository: string(node.Repository.NameWithOwner)}) + } + return items, int(query.Node.UserList.Items.TotalCount), query.Node.UserList.Items.PageInfo, nil +} + +// repoInList reports whether the repository identified by repoID belongs to the +// list identified by listID, paging through the list's items until a match is +// found or the connection is exhausted. +func repoInList(ctx context.Context, client *githubv4.Client, listID, repoID githubv4.ID, after *githubv4.String) (bool, error) { + for { + var query struct { + Node struct { + UserList struct { + Items struct { + Nodes []struct { + Repository struct { + ID githubv4.ID + } `graphql:"... on Repository"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"items(first: 100, after: $after)"` + } `graphql:"... on UserList"` + } `graphql:"node(id: $id)"` + } + vars := map[string]any{ + "id": listID, + "after": after, + } + if err := client.Query(ctx, &query, vars); err != nil { + return false, err + } + for _, node := range query.Node.UserList.Items.Nodes { + if node.Repository.ID == repoID { + return true, nil + } + } + if !query.Node.UserList.Items.PageInfo.HasNextPage { + return false, nil + } + cursor := githubv4.String(query.Node.UserList.Items.PageInfo.EndCursor) + after = &cursor + } +} + +// createUserList creates a new star list for the authenticated user. +func createUserList(ctx context.Context, client *githubv4.Client, name, description string, isPrivate *bool) (string, error) { + input := githubv4.CreateUserListInput{ + Name: githubv4.String(name), + } + if description != "" { + d := githubv4.String(description) + input.Description = &d + } + if isPrivate != nil { + p := githubv4.Boolean(*isPrivate) + input.IsPrivate = &p + } + + var mutation struct { + CreateUserList struct { + List struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"createUserList(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return "", err + } + return string(mutation.CreateUserList.List.Name), nil +} + +// updateUserList updates the name, description, and/or privacy of an existing +// star list. name identifies the list; newName, description, and isPrivate are +// optional changes. description is nil when the field was omitted (leave +// unchanged) and non-nil when supplied (including an explicit empty string, +// which clears the description). +func updateUserList(ctx context.Context, client *githubv4.Client, name, newName string, description *string, isPrivate *bool) (string, error) { + listID, err := getUserListID(ctx, client, name) + if err != nil { + return "", err + } + + input := githubv4.UpdateUserListInput{ + ListID: listID, + } + if newName != "" { + n := githubv4.String(newName) + input.Name = &n + } + if description != nil { + d := githubv4.String(*description) + input.Description = &d + } + if isPrivate != nil { + p := githubv4.Boolean(*isPrivate) + input.IsPrivate = &p + } + + var mutation struct { + UpdateUserList struct { + List struct { + Name githubv4.String + } + } `graphql:"updateUserList(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return "", err + } + return string(mutation.UpdateUserList.List.Name), nil +} + +// deleteUserList deletes a star list owned by the authenticated user. +func deleteUserList(ctx context.Context, client *githubv4.Client, name string) error { + listID, err := getUserListID(ctx, client, name) + if err != nil { + return err + } + + input := githubv4.DeleteUserListInput{ + ListID: listID, + } + var mutation struct { + DeleteUserList struct { + ClientMutationID githubv4.String + } `graphql:"deleteUserList(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return err + } + return nil +} + +// setRepoListMemberships adds (add=true) or removes (add=false) a repository +// from the named list. updateUserListsForItem REPLACES the repository's full +// list membership, so the current set is read first, merged/subtracted, and +// resubmitted in full. +// +// GitHub's schema has no reverse lookup from a repository to its lists (there +// is no `lists` field on Repository). Membership is instead derived by walking +// the viewer's lists and checking each list's items for the repository's node +// ID. Both the lists and each list's items are fully paginated so a repository +// beyond the first 100 items of a list is still counted; omitting it here would +// silently drop the repository from that list on the subsequent mutation. +func setRepoListMemberships(ctx context.Context, client *githubv4.Client, owner, repo, listName string, add bool) error { + listID, err := getUserListID(ctx, client, listName) + if err != nil { + return err + } + + repoID, err := getRepositoryID(ctx, client, owner, repo) + if err != nil { + return fmt.Errorf("failed to find repository: %w", err) + } + if repoID == nil || repoID == "" { + return fmt.Errorf("repository '%s/%s' not found", owner, repo) + } + + // Walk every list and, for each, every page of items to determine which + // lists currently contain the repository. + var listIDs []githubv4.ID + var listsAfter *githubv4.String + for { + var query struct { + Viewer struct { + Lists struct { + Nodes []struct { + ID githubv4.ID + Items struct { + Nodes []struct { + Repository struct { + ID githubv4.ID + } `graphql:"... on Repository"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"items(first: 100)"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"lists(first: 100, after: $listsAfter)"` + } + } + vars := map[string]any{ + "listsAfter": listsAfter, + } + if err := client.Query(ctx, &query, vars); err != nil { + return err + } + for _, list := range query.Viewer.Lists.Nodes { + contains := false + for _, item := range list.Items.Nodes { + if item.Repository.ID == repoID { + contains = true + break + } + } + // If the first page didn't contain the repository but the list has + // more than 100 items, keep paging until we know for certain. + if !contains && list.Items.PageInfo.HasNextPage { + cursor := githubv4.String(list.Items.PageInfo.EndCursor) + var err error + contains, err = repoInList(ctx, client, list.ID, repoID, &cursor) + if err != nil { + return err + } + } + if contains { + listIDs = append(listIDs, list.ID) + } + } + + if !query.Viewer.Lists.PageInfo.HasNextPage { + break + } + cursor := githubv4.String(query.Viewer.Lists.PageInfo.EndCursor) + listsAfter = &cursor + } + + present := false + for _, id := range listIDs { + if id == listID { + present = true + break + } + } + if add == present { + return nil + } + + result := make([]githubv4.ID, 0, len(listIDs)+1) + if add { + for _, id := range listIDs { + result = append(result, id) + } + if !present { + result = append(result, listID) + } + } else { + for _, id := range listIDs { + if id != listID { + result = append(result, id) + } + } + } + + input := githubv4.UpdateUserListsForItemInput{ + ItemID: repoID, + ListIDs: result, + } + var mutation struct { + UpdateUserListsForItem struct { + ClientMutationID githubv4.String + } `graphql:"updateUserListsForItem(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return err + } + return nil +} + +// ListUserLists creates a tool to list the authenticated user's star lists. +func ListUserLists(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "list_user_lists", + Description: t("TOOL_LIST_USER_LISTS_DESCRIPTION", "List a page of the authenticated user's star lists (UserLists), optionally including the first page of repositories in each list."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_USER_LISTS_USER_TITLE", "List star lists"), + ReadOnlyHint: true, + }, + InputSchema: WithCursorPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "include_items": { + Type: "boolean", + Description: "Whether to include up to 100 repositories and item cursor metadata for each returned list.", + }, + }, + }), + }, + userListReadScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + includeItems, err := OptionalParam[bool](args, "include_items") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + first := githubv4.Int(*paginationParams.First) + var after *githubv4.String + if paginationParams.After != nil { + cursor := githubv4.String(*paginationParams.After) + after = &cursor + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + lists, totalCount, pageInfo, err := listUserLists(ctx, client, includeItems, first, after) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list user lists", err), nil, nil + } + + response := map[string]any{ + "lists": lists, + "pageInfo": pageInfo, + "totalCount": totalCount, + } + out, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal user lists: %w", err) + } + result := utils.NewToolResultText(string(out)) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelUserList()) + return result, nil, nil + }, + ) +} + +// ListUserListItems creates a tool to page through the repositories in one +// user list. Use the per-list cursor returned by list_user_lists when its item +// preview indicates that additional pages exist. +func ListUserListItems(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "list_user_list_items", + Description: t("TOOL_LIST_USER_LIST_ITEMS_DESCRIPTION", "List a page of repositories in one star list (UserList)."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_USER_LIST_ITEMS_USER_TITLE", "List star list items"), + ReadOnlyHint: true, + }, + InputSchema: WithCursorPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The name of the star list whose repositories should be listed.", + }, + }, + Required: []string{"name"}, + }), + }, + scopes.RequireAll(scopes.ReadUser, scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + name, err := RequiredParam[string](args, "name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + first := githubv4.Int(*paginationParams.First) + var after *githubv4.String + if paginationParams.After != nil { + cursor := githubv4.String(*paginationParams.After) + after = &cursor + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + listID, err := getUserListID(ctx, client, name) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find user list", err), nil, nil + } + items, totalCount, pageInfo, err := listUserListItemsPage(ctx, client, listID, first, after) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list user-list items", err), nil, nil + } + response := map[string]any{ + "items": items, + "pageInfo": pageInfo, + "totalCount": totalCount, + } + result := MarshalledTextResult(response) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelUserList()) + return result, nil, nil + }, + ) +} + +// CreateUserList creates a tool to create a new star list. +func CreateUserList(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "create_user_list", + Description: t("TOOL_CREATE_USER_LIST_DESCRIPTION", "Create a new star list (UserList) for the authenticated user."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_USER_LIST_USER_TITLE", "Create star list"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The name of the new list.", + }, + "description": { + Type: "string", + Description: "A description of the list.", + }, + "is_private": { + Type: "boolean", + Description: "Whether the list is private.", + }, + }, + Required: []string{"name"}, + }, + }, + scopes.RequireAll(scopes.User), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + name, err := RequiredParam[string](args, "name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, err := OptionalParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + isPrivate, present, err := OptionalParamOK[bool](args, "is_private") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var isPrivatePtr *bool + if present { + isPrivatePtr = &isPrivate + } + createdName, err := createUserList(ctx, client, name, description, isPrivatePtr) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create user list", err), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("list '%s' created successfully", createdName)), nil, nil + }, + ) +} + +// UpdateUserList creates a tool to update an existing star list. +func UpdateUserList(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "update_user_list", + Description: t("TOOL_UPDATE_USER_LIST_DESCRIPTION", "Update an existing star list (UserList): rename it, change its description, or change its privacy."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_USER_LIST_USER_TITLE", "Update star list"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The current name of the list to update.", + }, + "new_name": { + Type: "string", + Description: "The new name for the list.", + }, + "description": { + Type: "string", + Description: "The new description for the list.", + }, + "is_private": { + Type: "boolean", + Description: "Whether the list is private.", + }, + }, + Required: []string{"name"}, + }, + }, + scopes.RequireAll(scopes.User), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + name, err := RequiredParam[string](args, "name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + newName, err := OptionalParam[string](args, "new_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, descPresent, err := OptionalParamOK[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + isPrivate, present, err := OptionalParamOK[bool](args, "is_private") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if newName == "" && !descPresent && !present { + return utils.NewToolResultError("at least one of new_name, description, or is_private must be provided for update"), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var isPrivatePtr *bool + if present { + isPrivatePtr = &isPrivate + } + var descriptionPtr *string + if descPresent { + descriptionPtr = &description + } + updatedName, err := updateUserList(ctx, client, name, newName, descriptionPtr, isPrivatePtr) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update user list", err), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("list '%s' updated successfully", updatedName)), nil, nil + }, + ) +} + +// DeleteUserList creates a tool to delete a star list. +func DeleteUserList(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "delete_user_list", + Description: t("TOOL_DELETE_USER_LIST_DESCRIPTION", "Delete a star list (UserList) owned by the authenticated user."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DELETE_USER_LIST_USER_TITLE", "Delete star list"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The name of the list to delete.", + }, + }, + Required: []string{"name"}, + }, + }, + scopes.RequireAll(scopes.User), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + name, err := RequiredParam[string](args, "name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + if err := deleteUserList(ctx, client, name); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete user list", err), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("list '%s' deleted successfully", name)), nil, nil + }, + ) +} + +// AddRepositoryToList creates a tool to add a repository to a star list. +func AddRepositoryToList(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "add_repository_to_list", + Description: t("TOOL_ADD_REPOSITORY_TO_LIST_DESCRIPTION", "Add a repository to a star list (UserList). List membership is independent of star state."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_REPOSITORY_TO_LIST_USER_TITLE", "Add repository to star list"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + IdempotentHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "list_name": { + Type: "string", + Description: "The name of the star list to add the repository to.", + }, + }, + Required: []string{"owner", "repo", "list_name"}, + }, + }, + scopes.RequireAll(scopes.User, scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + listName, err := RequiredParam[string](args, "list_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + if err := setRepoListMemberships(ctx, client, owner, repo, listName, true); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to add repository to list", err), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("repository %s/%s added to list '%s'", owner, repo, listName)), nil, nil + }, + ) +} + +// RemoveRepositoryFromList creates a tool to remove a repository from a star list. +func RemoveRepositoryFromList(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataStargazers, + mcp.Tool{ + Name: "remove_repository_from_list", + Description: t("TOOL_REMOVE_REPOSITORY_FROM_LIST_DESCRIPTION", "Remove a repository from a star list (UserList). List membership is independent of star state."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REMOVE_REPOSITORY_FROM_LIST_USER_TITLE", "Remove repository from star list"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + IdempotentHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "list_name": { + Type: "string", + Description: "The name of the star list to remove the repository from.", + }, + }, + Required: []string{"owner", "repo", "list_name"}, + }, + }, + scopes.RequireAll(scopes.User, scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + listName, err := RequiredParam[string](args, "list_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + if err := setRepoListMemberships(ctx, client, owner, repo, listName, false); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to remove repository from list", err), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("repository %s/%s removed from list '%s'", owner, repo, listName)), nil, nil + }, + ) +} diff --git a/pkg/github/user_lists_test.go b/pkg/github/user_lists_test.go new file mode 100644 index 0000000000..6be676037c --- /dev/null +++ b/pkg/github/user_lists_test.go @@ -0,0 +1,1066 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListUserLists(t *testing.T) { + t.Parallel() + + serverTool := ListUserLists(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_user_lists", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint, "list_user_lists tool should be read-only") + + // Scope gating: list metadata needs read:user, while include_items also + // requires repo so private repository memberships are not silently omitted. + assert.Equal(t, []string{"read:user", "repo"}, serverTool.ScopeAccess.Scopes) + assert.NotNil(t, serverTool.ScopeAccess.Visible) + assert.NotNil(t, serverTool.ScopeAccess.Challenge) + assert.True(t, serverTool.ScopeAccess.Dynamic) + assert.False(t, serverTool.ScopeAccess.Visible(nil)) + assert.True(t, serverTool.ScopeAccess.Visible([]string{"read:user"})) + assert.Empty(t, serverTool.ScopeAccess.Challenge(map[string]any{}, []string{"read:user"})) + assert.ElementsMatch(t, []string{"read:user", "repo"}, serverTool.ScopeAccess.Challenge( + map[string]any{"include_items": true}, []string{"read:user"}, + )) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + }{ + { + name: "list user lists without items", + requestArgs: map[string]any{ + "include_items": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListPageQuery{}, + map[string]any{"first": githubv4.Int(30), "after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("list-1"), + "name": githubv4.String("My list"), + "description": githubv4.String("A list"), + "isPrivate": githubv4.Boolean(true), + }, + }, + "totalCount": githubv4.Int(1), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "list user lists with items", + requestArgs: map[string]any{ + "include_items": true, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListPageWithItemsQuery{}, + map[string]any{"first": githubv4.Int(30), "after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("list-1"), + "name": githubv4.String("My list"), + "description": githubv4.String("A list"), + "isPrivate": githubv4.Boolean(false), + "items": map[string]any{ + "nodes": []any{ + map[string]any{"nameWithOwner": githubv4.String("owner/repo")}, + }, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + }, + }, + }, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + "totalCount": githubv4.Int(1), + }, + }, + }), + ), + ), + expectToolError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + if tc.expectToolError { + assert.True(t, result.IsError) + } else { + assert.False(t, result.IsError) + } + }) + } +} + +func TestUserListItemsJSONDistinguishesNotFetchedFromEmpty(t *testing.T) { + t.Parallel() + + notFetched, err := json.Marshal(userList{}) + require.NoError(t, err) + assert.Contains(t, string(notFetched), `"items":null`) + + fetchedEmpty, err := json.Marshal(userList{Items: []userListItem{}}) + require.NoError(t, err) + assert.Contains(t, string(fetchedEmpty), `"items":[]`) +} + +func TestListUserListsIFCLabel(t *testing.T) { + t.Parallel() + + serverTool := ListUserLists(translations.NullTranslationHelper) + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListPageQuery{}, + map[string]any{"first": githubv4.Int(30), "after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{}, + "totalCount": githubv4.Int(0), + }, + }, + }), + ), + ) + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "unexpected tool error: %s", getTextResult(t, result).Text) + + label, ok := result.Meta["ifc"].(ifc.SecurityLabel) + require.True(t, ok) + assert.Equal(t, ifc.PrivateTrusted(), label) +} + +func TestGetUserListIDPaginates(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Nil(t, req.Variables["after"]) + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-1","name":"Other"}],"pageInfo":{"hasNextPage":true,"endCursor":"next-list"}}}}}` + }, + func(req capturedGraphQLRequest) (int, string) { + assert.Equal(t, "next-list", req.Variables["after"]) + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-2","name":"Target"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + }, + }, + } + client := githubv4.NewClient(&http.Client{Transport: transport}) + + id, err := getUserListID(context.Background(), client, "Target") + require.NoError(t, err) + assert.Equal(t, githubv4.ID("list-2"), id) + require.Len(t, transport.calls, 2) +} + +func TestListUserListsReturnsRequestedPage(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Equal(t, "next-list", req.Variables["after"]) + assert.Equal(t, float64(1), req.Variables["first"]) + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-2","name":"Second","description":"","isPrivate":true}],"pageInfo":{"hasNextPage":false,"hasPreviousPage":true,"startCursor":"second","endCursor":"second"},"totalCount":2}}}}` + }, + }, + } + client := githubv4.NewClient(&http.Client{Transport: transport}) + after := githubv4.String("next-list") + + lists, totalCount, pageInfo, err := listUserLists(context.Background(), client, false, 1, &after) + require.NoError(t, err) + require.Len(t, lists, 1) + assert.Equal(t, "Second", lists[0].Name) + assert.Equal(t, 2, totalCount) + assert.True(t, pageInfo.HasPreviousPage) + assert.Equal(t, "second", pageInfo.EndCursor) + require.Len(t, transport.calls, 1) +} + +func TestListUserListsIncludesBoundedItemPage(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Nil(t, req.Variables["after"]) + assert.Equal(t, float64(30), req.Variables["first"]) + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-1","name":"List","description":"","isPrivate":false,"items":{"nodes":[{"nameWithOwner":"owner/first"}],"pageInfo":{"hasNextPage":true,"endCursor":"next-item"}}}],"pageInfo":{"hasNextPage":false,"endCursor":""},"totalCount":1}}}}` + }, + }, + } + client := githubv4.NewClient(&http.Client{Transport: transport}) + + lists, totalCount, _, err := listUserLists(context.Background(), client, true, 30, nil) + require.NoError(t, err) + require.Len(t, lists, 1) + assert.Equal(t, 1, totalCount) + assert.Equal(t, []userListItem{{Repository: "owner/first"}}, lists[0].Items) + require.NotNil(t, lists[0].ItemsPageInfo) + assert.True(t, lists[0].ItemsPageInfo.HasNextPage) + assert.Equal(t, "next-item", lists[0].ItemsPageInfo.EndCursor) + require.Len(t, transport.calls, 1) +} + +func TestListUserListItems(t *testing.T) { + t.Parallel() + + serverTool := ListUserListItems(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Equal(t, "list_user_list_items", tool.Name) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Equal(t, []string{"read:user", "repo"}, serverTool.ScopeAccess.Scopes) + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{map[string]any{"id": githubv4.ID("list-1"), "name": githubv4.String("List")}}, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + userListItemsPageQuery{}, + map[string]any{ + "id": githubv4.ID("list-1"), + "first": githubv4.Int(1), + "after": githubv4.String("item-start"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "items": map[string]any{ + "nodes": []any{map[string]any{"nameWithOwner": githubv4.String("owner/repo")}}, + "pageInfo": map[string]any{ + "hasNextPage": false, + "endCursor": "item-cursor", + }, + "totalCount": githubv4.Int(1), + }, + }, + }), + ), + ) + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{"name": "List", "perPage": float64(1), "after": "item-start"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "unexpected tool error: %s", getTextResult(t, result).Text) + assert.Contains(t, getTextResult(t, result).Text, `"repository":"owner/repo"`) + assert.Contains(t, getTextResult(t, result).Text, `"endCursor":"item-cursor"`) +} + +func TestCreateUserList(t *testing.T) { + t.Parallel() + + serverTool := CreateUserList(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_user_list", tool.Name) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.False(t, *tool.Annotations.DestructiveHint) + + // scope gating: RequireAll(User) + assert.Equal(t, []string{"user"}, serverTool.ScopeAccess.Scopes) + assert.NotNil(t, serverTool.ScopeAccess.Visible) + assert.NotNil(t, serverTool.ScopeAccess.Challenge) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + }{ + { + name: "create list", + requestArgs: map[string]any{ + "name": "My list", + "description": "A list", + "is_private": true, + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + CreateUserList struct { + List struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"createUserList(input: $input)"` + }{}, + githubv4.CreateUserListInput{ + Name: githubv4.String("My list"), + Description: func() *githubv4.String { s := githubv4.String("A list"); return &s }(), + IsPrivate: func() *githubv4.Boolean { b := githubv4.Boolean(true); return &b }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createUserList": map[string]any{ + "list": map[string]any{ + "id": githubv4.ID("list-1"), + "name": githubv4.String("My list"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tc.expectToolError, result.IsError) + }) + } +} + +func TestUpdateUserList(t *testing.T) { + t.Parallel() + + serverTool := UpdateUserList(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_user_list", tool.Name) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.False(t, *tool.Annotations.DestructiveHint) + assert.Equal(t, []string{"user"}, serverTool.ScopeAccess.Scopes) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "update list name", + requestArgs: map[string]any{ + "name": "Old name", + "new_name": "New name", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("list-1"), + "name": githubv4.String("Old name"), + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateUserList struct { + List struct { + Name githubv4.String + } + } `graphql:"updateUserList(input: $input)"` + }{}, + githubv4.UpdateUserListInput{ + ListID: githubv4.ID("list-1"), + Name: func() *githubv4.String { s := githubv4.String("New name"); return &s }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateUserList": map[string]any{ + "list": map[string]any{ + "name": githubv4.String("New name"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "update list not found", + requestArgs: map[string]any{ + "name": "Missing", + "new_name": "New name", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "list 'Missing' not found", + }, + { + name: "update without changes", + requestArgs: map[string]any{ + "name": "My list", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "at least one of new_name, description, or is_private must be provided for update", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + if tc.expectToolError { + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) + } + }) + } +} + +func TestDeleteUserList(t *testing.T) { + t.Parallel() + + serverTool := DeleteUserList(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_user_list", tool.Name) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + assert.Equal(t, []string{"user"}, serverTool.ScopeAccess.Scopes) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "delete list", + requestArgs: map[string]any{ + "name": "My list", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("list-1"), + "name": githubv4.String("My list"), + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + DeleteUserList struct { + ClientMutationID githubv4.String + } `graphql:"deleteUserList(input: $input)"` + }{}, + githubv4.DeleteUserListInput{ + ListID: githubv4.ID("list-1"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteUserList": map[string]any{ + "clientMutationId": githubv4.String("test-mutation-id"), + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "delete list not found", + requestArgs: map[string]any{ + "name": "Missing", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "list 'Missing' not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + if tc.expectToolError { + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) + } + }) + } +} + +func TestAddRepositoryToList(t *testing.T) { + t.Parallel() + + serverTool := AddRepositoryToList(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_repository_to_list", tool.Name) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.False(t, *tool.Annotations.DestructiveHint) + assert.True(t, tool.Annotations.IdempotentHint) + assert.Equal(t, []string{"user", "repo"}, serverTool.ScopeAccess.Scopes) + + // Repository currently in lists "A" and "B"; adding to "C" must resubmit all + // three because updateUserListsForItem REPLACES membership (does not append). + mockedClient := githubv4mock.NewMockedHTTPClient( + // 1. resolve list "C" -> list-c + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{"id": githubv4.ID("list-c"), "name": githubv4.String("C")}, + }, + }, + }, + }), + ), + // 2. resolve repository -> repo-id + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("repo-id"), + }, + }), + ), + // 3. read current list membership by walking the viewer's lists -> A, B + githubv4mock.NewQueryMatcher( + struct { + Viewer struct { + Lists struct { + Nodes []struct { + ID githubv4.ID + Items struct { + Nodes []struct { + Repository struct { + ID githubv4.ID + } `graphql:"... on Repository"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"items(first: 100)"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"lists(first: 100, after: $listsAfter)"` + } + }{}, + map[string]any{ + "listsAfter": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("list-a"), + "items": map[string]any{ + "nodes": []any{ + map[string]any{"id": githubv4.ID("repo-id")}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "endCursor": "", + }, + }, + }, + map[string]any{ + "id": githubv4.ID("list-b"), + "items": map[string]any{ + "nodes": []any{ + map[string]any{"id": githubv4.ID("repo-id")}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "endCursor": "", + }, + }, + }, + map[string]any{ + "id": githubv4.ID("list-c"), + "items": map[string]any{ + "nodes": []any{}, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "endCursor": "", + }, + }, + }, + }), + ), + // 4. resubmit full set A, B, C + githubv4mock.NewMutationMatcher( + struct { + UpdateUserListsForItem struct { + ClientMutationID githubv4.String + } `graphql:"updateUserListsForItem(input: $input)"` + }{}, + githubv4.UpdateUserListsForItemInput{ + ItemID: githubv4.ID("repo-id"), + ListIDs: []githubv4.ID{"list-a", "list-b", "list-c"}, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateUserListsForItem": map[string]any{ + "clientMutationId": githubv4.String("test-mutation-id"), + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "list_name": "C", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) +} + +func TestRemoveRepositoryFromList(t *testing.T) { + t.Parallel() + + serverTool := RemoveRepositoryFromList(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "remove_repository_from_list", tool.Name) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + assert.True(t, tool.Annotations.IdempotentHint) + assert.Equal(t, []string{"user", "repo"}, serverTool.ScopeAccess.Scopes) + + // Repository currently in lists "A" and "B"; removing from "B" must resubmit + // only the remainder ("A"). + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{"id": githubv4.ID("list-b"), "name": githubv4.String("B")}, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("repo-id"), + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Viewer struct { + Lists struct { + Nodes []struct { + ID githubv4.ID + Items struct { + Nodes []struct { + Repository struct { + ID githubv4.ID + } `graphql:"... on Repository"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"items(first: 100)"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"lists(first: 100, after: $listsAfter)"` + } + }{}, + map[string]any{ + "listsAfter": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("list-a"), + "items": map[string]any{ + "nodes": []any{ + map[string]any{"id": githubv4.ID("repo-id")}, + }, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + }, + }, + map[string]any{ + "id": githubv4.ID("list-b"), + "items": map[string]any{ + "nodes": []any{ + map[string]any{"id": githubv4.ID("repo-id")}, + }, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "endCursor": "", + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateUserListsForItem struct { + ClientMutationID githubv4.String + } `graphql:"updateUserListsForItem(input: $input)"` + }{}, + githubv4.UpdateUserListsForItemInput{ + ItemID: githubv4.ID("repo-id"), + ListIDs: []githubv4.ID{"list-a"}, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateUserListsForItem": map[string]any{ + "clientMutationId": githubv4.String("test-mutation-id"), + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "list_name": "B", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) +} + +func TestAddRepositoryToListListNotFound(t *testing.T) { + t.Parallel() + + serverTool := AddRepositoryToList(translations.NullTranslationHelper) + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "list_name": "Missing", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, "list 'Missing' not found") +} + +func TestAddRepositoryToListRepositoryNotFound(t *testing.T) { + t.Parallel() + + serverTool := AddRepositoryToList(translations.NullTranslationHelper) + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + userListLookupQuery{}, + map[string]any{"after": (*githubv4.String)(nil)}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "lists": map[string]any{ + "nodes": []any{map[string]any{"id": githubv4.ID("list-c"), "name": githubv4.String("C")}}, + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{"owner": githubv4.String("owner"), "repo": githubv4.String("repo")}, + githubv4mock.DataResponse(map[string]any{"repository": nil}), + ), + ) + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "list_name": "C"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "repository 'owner/repo' not found") +} + +func TestSetRepoListMembershipsSkipsSatisfiedMutation(t *testing.T) { + tests := []struct { + name string + add bool + currentlyInIt bool + }{ + {name: "add already present", add: true, currentlyInIt: true}, + {name: "remove already absent", add: false, currentlyInIt: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + itemNodes := "[]" + if tc.currentlyInIt { + itemNodes = `[{"id":"repo-id"}]` + } + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(capturedGraphQLRequest) (int, string) { + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-c","name":"C"}]}}}}` + }, + func(capturedGraphQLRequest) (int, string) { + return http.StatusOK, `{"data":{"repository":{"id":"repo-id"}}}` + }, + func(capturedGraphQLRequest) (int, string) { + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-c","items":{"nodes":` + itemNodes + `,"pageInfo":{"hasNextPage":false,"endCursor":""}}}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + }, + }, + } + client := githubv4.NewClient(&http.Client{Transport: transport}) + + require.NoError(t, setRepoListMemberships(context.Background(), client, "owner", "repo", "C", tc.add)) + require.Len(t, transport.calls, 3, "satisfied operation should not issue a mutation") + }) + } +} + +func TestSetRepoListMembershipsPreservesMembershipFoundOnLaterPage(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(capturedGraphQLRequest) (int, string) { + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-c","name":"C"}]}}}}` + }, + func(capturedGraphQLRequest) (int, string) { + return http.StatusOK, `{"data":{"repository":{"id":"repo-id"}}}` + }, + func(capturedGraphQLRequest) (int, string) { + return http.StatusOK, `{"data":{"viewer":{"lists":{"nodes":[{"id":"list-a","items":{"nodes":[],"pageInfo":{"hasNextPage":true,"endCursor":"cursor-a"}}},{"id":"list-c","items":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":""}}}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + }, + func(req capturedGraphQLRequest) (int, string) { + assert.Equal(t, "cursor-a", req.Variables["after"]) + return http.StatusOK, `{"data":{"node":{"items":{"nodes":[{"id":"repo-id"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + }, + func(req capturedGraphQLRequest) (int, string) { + input, ok := req.Variables["input"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"list-a", "list-c"}, input["listIds"]) + return http.StatusOK, `{"data":{"updateUserListsForItem":{"clientMutationId":"test-mutation-id"}}}` + }, + }, + } + client := githubv4.NewClient(&http.Client{Transport: transport}) + + require.NoError(t, setRepoListMemberships(context.Background(), client, "owner", "repo", "C", true)) + require.Len(t, transport.calls, 5) +} diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 39c7e953b4..e3cbec884b 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -663,6 +663,7 @@ func TestSupportedScopes(t *testing.T) { "notifications", "workflow", "codespace", + "user", } assert.Equal(t, expectedScopes, SupportedScopes) diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index f23383ce73..0b62c67d56 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -73,6 +73,15 @@ func LabelGetMe() SecurityLabel { return PrivateTrusted() } +// LabelUserList returns the IFC label for the authenticated user's star lists. +// List names and descriptions may be private, and included items may expose +// memberships in private repositories, so the joined result is conservatively +// private. Integrity is trusted because the lists are maintained by the +// authenticated user through GitHub. +func LabelUserList() SecurityLabel { + return PrivateTrusted() +} + // LabelListIssues returns the IFC label for a list_issues result. // Public repositories are universally readable; private repositories are // restricted to their collaborators (resolved client-side from the marker). diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index f4b25c1876..5b7d1fdb4a 100644 --- a/pkg/ifc/ifc_test.go +++ b/pkg/ifc/ifc_test.go @@ -121,6 +121,12 @@ func TestLabelGetMe(t *testing.T) { assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) } +func TestLabelUserList(t *testing.T) { + t.Parallel() + + assert.Equal(t, PrivateTrusted(), LabelUserList()) +} + func TestLabelRelease(t *testing.T) { t.Parallel() diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index d845cc6dc8..b89d009a44 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -87,6 +87,7 @@ var oauthScopeDefinitions = []oauthScopeDefinition{ {scope: Notifications, byDefault: true}, {scope: Workflow}, {scope: Codespace}, + {scope: User}, } // SupportedOAuthScopes returns every OAuth scope the server may request. diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 62bfc6178e..2af00e3e98 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -17,6 +17,8 @@ func TestOAuthScopeCatalog(t *testing.T) { assert.NotContains(t, defaults, string(Workflow)) assert.Contains(t, supported, string(Codespace)) assert.NotContains(t, defaults, string(Codespace)) + assert.Contains(t, supported, string(User)) + assert.NotContains(t, defaults, string(User)) } func TestScopeChecks(t *testing.T) {