From 74964520cf15aae32400119762a9d4482eda0333 Mon Sep 17 00:00:00 2001 From: Bupal Chowdary <136565586+Bupalchow@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:09:36 +0530 Subject: [PATCH 01/13] Added installation instructions for mcpcurl (#719) * Added installation instructions for mcpcurl * Update cmd/mcpcurl/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/mcpcurl/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index 317c2b8e5..717ea207f 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -15,6 +15,26 @@ be executed against the configured MCP server. ## Installation +### Prerequisites +- Go 1.21 or later +- Access to the GitHub MCP Server from either Docker or local build + +### Build from Source +```bash +cd cmd/mcpcurl +go build -o mcpcurl +``` + +### Using Go Install +```bash +go install github.com/github/github-mcp-server/cmd/mcpcurl@latest +``` + +### Verify Installation +```bash +./mcpcurl --help +``` + ## Usage ```console From 7ccc6b6493f184c6fce8620c41ed1ffda12837b8 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 21 Jul 2025 16:31:29 +0100 Subject: [PATCH 02/13] Add pagination support to GraphQL-based tools (#683) * initial pagination for `ListDiscussions` * redo category id var cast * add GraphQL pagination support for discussion comments and categories * remove pageinfo returns * fix out ref for linter * update docs * move to unified pagination for consensus on params * update docs * refactor pagination handling * update docs * linter fix * conv rest to gql params for safe lint * add nolint * add error handling for perPage value in ToGraphQLParams * refactor pagination error handling * unified params for rest andn graphql and rennamed to be uniform for golang * add 'after' for pagination * update docs * Update pkg/github/discussions.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/github/discussions.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/github/discussions_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * update default page size const * reduce default pagination size from 100 to 30 in discussion tests * update pagination for reverse and total * update pagination to remove from discussions * updated README * improve the `ToGraphQLParams` function --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 8 +- pkg/github/actions.go | 16 +-- pkg/github/discussions.go | 202 ++++++++++++++++++++++------- pkg/github/discussions_test.go | 225 ++++++++++++++++++--------------- pkg/github/issues.go | 4 +- pkg/github/notifications.go | 4 +- pkg/github/pullrequests.go | 8 +- pkg/github/repositories.go | 16 +-- pkg/github/search.go | 12 +- pkg/github/search_utils.go | 4 +- pkg/github/server.go | 116 +++++++++++++++-- pkg/github/server_test.go | 16 +-- 12 files changed, 430 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index ae4d3627e..c06142b76 100644 --- a/README.md +++ b/README.md @@ -449,21 +449,21 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - **get_discussion_comments** - Get discussion comments + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **list_discussion_categories** - List discussion categories - - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional) - - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional) - - `first`: Number of categories to return per page (min 1, max 100) (number, optional) - - `last`: Number of categories to return from the end (min 1, max 100) (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **list_discussions** - List discussions + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 3c441d5aa..19b56389c 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -62,8 +62,8 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) // Set up list options opts := &github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, } workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) @@ -200,8 +200,8 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun Event: event, Status: status, ListOptions: github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } @@ -503,8 +503,8 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun opts := &github.ListWorkflowJobsOptions{ Filter: filter, ListOptions: github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } @@ -1025,8 +1025,8 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH // Set up list options opts := &github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, } artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 23e2724d4..2b8ccfb0b 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -13,6 +13,8 @@ import ( "github.com/shurcooL/githubv4" ) +const DefaultGraphQLPageSize = 30 + func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_discussions", mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")), @@ -31,6 +33,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.WithString("category", mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), ), + WithCursorPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Required params @@ -49,6 +52,16 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp return mcp.NewToolResultError(err.Error()), nil } + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } + client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil @@ -61,7 +74,8 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp categoryID = &id } - // Now execute the discussions query + var out []byte + var discussions []*github.Discussion if categoryID != nil { // Query with category filter (server-side filtering) @@ -77,13 +91,26 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(first: 100, categoryId: $categoryId)"` + PageInfo struct { + HasNextPage bool + HasPreviousPage bool + StartCursor string + EndCursor string + } + TotalCount int + } `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "categoryId": *categoryID, + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) } if err := client.Query(ctx, &query, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -102,6 +129,23 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } discussions = append(discussions, di) } + + // Create response with pagination info + response := map[string]interface{}{ + "discussions": discussions, + "pageInfo": map[string]interface{}{ + "hasNextPage": query.Repository.Discussions.PageInfo.HasNextPage, + "hasPreviousPage": query.Repository.Discussions.PageInfo.HasPreviousPage, + "startCursor": query.Repository.Discussions.PageInfo.StartCursor, + "endCursor": query.Repository.Discussions.PageInfo.EndCursor, + }, + "totalCount": query.Repository.Discussions.TotalCount, + } + + out, err = json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussions: %w", err) + } } else { // Query without category filter var query struct { @@ -116,12 +160,25 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(first: 100)"` + PageInfo struct { + HasNextPage bool + HasPreviousPage bool + StartCursor string + EndCursor string + } + TotalCount int + } `graphql:"discussions(first: $first, after: $after)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) } if err := client.Query(ctx, &query, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -140,13 +197,25 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } discussions = append(discussions, di) } - } - // Marshal and return - out, err := json.Marshal(discussions) - if err != nil { - return nil, fmt.Errorf("failed to marshal discussions: %w", err) + // Create response with pagination info + response := map[string]interface{}{ + "discussions": discussions, + "pageInfo": map[string]interface{}{ + "hasNextPage": query.Repository.Discussions.PageInfo.HasNextPage, + "hasPreviousPage": query.Repository.Discussions.PageInfo.HasPreviousPage, + "startCursor": query.Repository.Discussions.PageInfo.StartCursor, + "endCursor": query.Repository.Discussions.PageInfo.EndCursor, + }, + "totalCount": query.Repository.Discussions.TotalCount, + } + + out, err = json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussions: %w", err) + } } + return mcp.NewToolResultText(string(out)), nil } } @@ -236,6 +305,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), + WithCursorPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params @@ -248,6 +318,27 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati return mcp.NewToolResultError(err.Error()), nil } + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + + // Check if pagination parameters were explicitly provided + _, perPageProvided := request.GetArguments()["perPage"] + paginationExplicit := perPageProvided + + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } + + // Use default of 30 if pagination was not explicitly provided + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst + } + client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil @@ -260,7 +351,14 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati Nodes []struct { Body githubv4.String } - } `graphql:"comments(first:100)"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -268,16 +366,35 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), "discussionNumber": githubv4.Int(params.DiscussionNumber), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } + var comments []*github.IssueComment for _, c := range q.Repository.Discussion.Comments.Nodes { comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) } - out, err := json.Marshal(comments) + // Create response with pagination info + response := map[string]interface{}{ + "comments": comments, + "pageInfo": map[string]interface{}{ + "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, + "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, + "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), + "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), + }, + "totalCount": q.Repository.Discussion.Comments.TotalCount, + } + + out, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal comments: %w", err) } @@ -301,55 +418,22 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithNumber("first", - mcp.Description("Number of categories to return per page (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - ), - mcp.WithNumber("last", - mcp.Description("Number of categories to return from the end (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - ), - mcp.WithString("after", - mcp.Description("Cursor for pagination, use the 'after' field from the previous response"), - ), - mcp.WithString("before", - mcp.Description("Cursor for pagination, use the 'before' field from the previous response"), - ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params var params struct { - Owner string - Repo string - First int32 - Last int32 - After string - Before string + Owner string + Repo string } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil } - // Validate pagination parameters - if params.First != 0 && params.Last != 0 { - return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil - } - if params.After != "" && params.Before != "" { - return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil - } - if params.After != "" && params.Last != 0 { - return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil - } - if params.Before != "" && params.First != 0 { - return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil - } - client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } + var q struct { Repository struct { DiscussionCategories struct { @@ -357,16 +441,25 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl ID githubv4.ID Name githubv4.String } - } `graphql:"discussionCategories(first: 100)"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"discussionCategories(first: $first)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), + "first": githubv4.Int(25), } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } + var categories []map[string]string for _, c := range q.Repository.DiscussionCategories.Nodes { categories = append(categories, map[string]string{ @@ -374,7 +467,20 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl "name": string(c.Name), }) } - out, err := json.Marshal(categories) + + // Create response with pagination info + response := map[string]interface{}{ + "categories": categories, + "pageInfo": map[string]interface{}{ + "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, + "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, + "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), + "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), + }, + "totalCount": q.Repository.DiscussionCategories.TotalCount, + } + + out, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index c6688a519..e2e3d99ed 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -27,12 +27,30 @@ var ( } mockResponseListAll = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ - "discussions": map[string]any{"nodes": discussionsAll}, + "discussions": map[string]any{ + "nodes": discussionsAll, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, }, }) mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ - "discussions": map[string]any{"nodes": discussionsGeneral}, + "discussions": map[string]any{ + "nodes": discussionsGeneral, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, }, }) mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") @@ -48,54 +66,32 @@ func Test_ListDiscussions(t *testing.T) { assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) - // mock for the call to ListDiscussions without category filter - var qDiscussions struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - } `graphql:"discussions(first: 100)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } + // Use exact string queries that match implementation output (from error messages) + qDiscussions := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - // mock for the call to get discussions with category filter - var qDiscussionsFiltered struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - } `graphql:"discussions(first: 100, categoryId: $categoryId)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } + qDiscussionsFiltered := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling varsListAll := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), + "owner": "owner", + "repo": "repo", + "first": float64(30), + "after": (*string)(nil), } varsRepoNotFound := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("nonexistent-repo"), + "owner": "owner", + "repo": "nonexistent-repo", + "first": float64(30), + "after": (*string)(nil), } varsDiscussionsFiltered := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "categoryId": githubv4.ID("DIC_kwDOABC123"), + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "first": float64(30), + "after": (*string)(nil), } tests := []struct { @@ -167,15 +163,25 @@ func Test_ListDiscussions(t *testing.T) { } require.NoError(t, err) - var returnedDiscussions []*github.Discussion - err = json.Unmarshal([]byte(text), &returnedDiscussions) + // Parse the structured response with pagination info + var response struct { + Discussions []*github.Discussion `json:"discussions"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + err = json.Unmarshal([]byte(text), &response) require.NoError(t, err) - assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions)) + assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) // Verify that all returned discussions have a category if filtered if _, hasCategory := tc.reqParams["category"]; hasCategory { - for _, discussion := range returnedDiscussions { + for _, discussion := range response.Discussions { require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") } @@ -194,23 +200,13 @@ func Test_GetDiscussion(t *testing.T) { assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) - var q struct { - Repository struct { - Discussion struct { - Number githubv4.Int - Body githubv4.String - CreatedAt githubv4.DateTime - URL githubv4.String `graphql:"url"` - Category struct { - Name githubv4.String - } `graphql:"category"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } + // Use exact string query that matches implementation output + qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,body,createdAt,url,category{name}}}}" + vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "discussionNumber": githubv4.Int(1), + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), } tests := []struct { name string @@ -250,7 +246,7 @@ func Test_GetDiscussion(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - matcher := githubv4mock.NewQueryMatcher(q, vars, tc.response) + matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) @@ -287,22 +283,18 @@ func Test_GetDiscussionComments(t *testing.T) { assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) - var q struct { - Repository struct { - Discussion struct { - Comments struct { - Nodes []struct { - Body githubv4.String - } - } `graphql:"comments(first:100)"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } + // Use exact string query that matches implementation output + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "discussionNumber": githubv4.Int(1), + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + "first": float64(30), + "after": (*string)(nil), } + mockResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ @@ -311,11 +303,18 @@ func Test_GetDiscussionComments(t *testing.T) { {"body": "This is the first comment"}, {"body": "This is the second comment"}, }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, }, }, }, }) - matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse) + matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) @@ -331,31 +330,38 @@ func Test_GetDiscussionComments(t *testing.T) { textContent := getTextResult(t, result) - var returnedComments []*github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + // (Lines removed) + + var response struct { + Comments []*github.IssueComment `json:"comments"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Len(t, returnedComments, 2) + assert.Len(t, response.Comments, 2) expectedBodies := []string{"This is the first comment", "This is the second comment"} - for i, comment := range returnedComments { + for i, comment := range response.Comments { assert.Equal(t, expectedBodies[i], *comment.Body) } } func Test_ListDiscussionCategories(t *testing.T) { - var q struct { - Repository struct { - DiscussionCategories struct { - Nodes []struct { - ID githubv4.ID - Name githubv4.String - } - } `graphql:"discussionCategories(first: 100)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } + // Use exact string query that matches implementation output + qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), + "owner": "owner", + "repo": "repo", + "first": float64(25), } + mockResp := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussionCategories": map[string]any{ @@ -363,10 +369,17 @@ func Test_ListDiscussionCategories(t *testing.T) { {"id": "123", "name": "CategoryOne"}, {"id": "456", "name": "CategoryTwo"}, }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, }, }, }) - matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp) + matcher := githubv4mock.NewQueryMatcher(qListCategories, vars, mockResp) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) @@ -382,11 +395,21 @@ func Test_ListDiscussionCategories(t *testing.T) { require.NoError(t, err) text := getTextResult(t, result).Text - var categories []map[string]string - require.NoError(t, json.Unmarshal([]byte(text), &categories)) - assert.Len(t, categories, 2) - assert.Equal(t, "123", categories[0]["id"]) - assert.Equal(t, "CategoryOne", categories[0]["name"]) - assert.Equal(t, "456", categories[1]["id"]) - assert.Equal(t, "CategoryTwo", categories[1]["name"]) + + var response struct { + Categories []map[string]string `json:"categories"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Len(t, response.Categories, 2) + assert.Equal(t, "123", response.Categories[0]["id"]) + assert.Equal(t, "CategoryOne", response.Categories[0]["name"]) + assert.Equal(t, "456", response.Categories[1]["id"]) + assert.Equal(t, "CategoryTwo", response.Categories[1]["name"]) } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 29d32bd18..80a150aba 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -630,8 +630,8 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, }, } diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index a41edaf42..fdd418098 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -88,8 +88,8 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu All: filter == FilterIncludeRead, Participating: filter == FilterOnlyParticipating, ListOptions: github.ListOptions{ - Page: paginationParams.page, - PerPage: paginationParams.perPage, + Page: paginationParams.Page, + PerPage: paginationParams.PerPage, }, } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d98dc334d..47b7c6bd2 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -403,8 +403,8 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun Sort: sort, Direction: direction, ListOptions: github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } @@ -622,8 +622,8 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper return nil, fmt.Errorf("failed to get GitHub client: %w", err) } opts := &github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, } files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) if err != nil { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 2e56c8644..ecd36d7e0 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -58,8 +58,8 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too } opts := &github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, } client, err := getClient(ctx) @@ -139,7 +139,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(err.Error()), nil } // Set default perPage to 30 if not provided - perPage := pagination.perPage + perPage := pagination.PerPage if perPage == 0 { perPage = 30 } @@ -147,7 +147,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t SHA: sha, Author: author, ListOptions: github.ListOptions{ - Page: pagination.page, + Page: pagination.Page, PerPage: perPage, }, } @@ -217,8 +217,8 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( opts := &github.BranchListOptions{ ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, }, } @@ -1198,8 +1198,8 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool } opts := &github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, } client, err := getClient(ctx) diff --git a/pkg/github/search.go b/pkg/github/search.go index 04a1facc0..b11bb3bbc 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -39,8 +39,8 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF opts := &github.SearchOptions{ ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, }, } @@ -118,8 +118,8 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to Sort: sort, Order: order, ListOptions: github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } @@ -193,8 +193,8 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand Sort: sort, Order: order, ListOptions: github.ListOptions{ - PerPage: pagination.perPage, - Page: pagination.page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 5dd48040e..a6ff1f782 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -56,8 +56,8 @@ func searchHandler( Sort: sort, Order: order, ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, }, } diff --git a/pkg/github/server.go b/pkg/github/server.go index ea476e3ac..193336b75 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -174,9 +174,7 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) } } -// WithPagination returns a ToolOption that adds "page" and "perPage" parameters to the tool. -// The "page" parameter is optional, min 1. -// The "perPage" parameter is optional, min 1, max 100. If unset, defaults to 30. +// WithPagination adds REST API pagination parameters to a tool. // https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api func WithPagination() mcp.ToolOption { return func(tool *mcp.Tool) { @@ -193,12 +191,49 @@ func WithPagination() mcp.ToolOption { } } +// WithUnifiedPagination adds REST API pagination parameters to a tool. +// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally. +func WithUnifiedPagination() mcp.ToolOption { + return func(tool *mcp.Tool) { + mcp.WithNumber("page", + mcp.Description("Page number for pagination (min 1)"), + mcp.Min(1), + )(tool) + + mcp.WithNumber("perPage", + mcp.Description("Results per page for pagination (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + )(tool) + + mcp.WithString("after", + mcp.Description("Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."), + )(tool) + } +} + +// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter). +func WithCursorPagination() mcp.ToolOption { + return func(tool *mcp.Tool) { + mcp.WithNumber("perPage", + mcp.Description("Results per page for pagination (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + )(tool) + + mcp.WithString("after", + mcp.Description("Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."), + )(tool) + } +} + type PaginationParams struct { - page int - perPage int + Page int + PerPage int + After string } -// OptionalPaginationParams returns the "page" and "perPage" parameters from the request, +// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request, // or their default values if not present, "page" default is 1, "perPage" default is 30. // In future, we may want to make the default values configurable, or even have this // function returned from `withPagination`, where the defaults are provided alongside @@ -212,12 +247,77 @@ func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) { if err != nil { return PaginationParams{}, err } + after, err := OptionalParam[string](r, "after") + if err != nil { + return PaginationParams{}, err + } return PaginationParams{ - page: page, - perPage: perPage, + Page: page, + PerPage: perPage, + After: after, + }, nil +} + +// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request, +// without the "page" parameter, suitable for cursor-based pagination only. +func OptionalCursorPaginationParams(r mcp.CallToolRequest) (CursorPaginationParams, error) { + perPage, err := OptionalIntParamWithDefault(r, "perPage", 30) + if err != nil { + return CursorPaginationParams{}, err + } + after, err := OptionalParam[string](r, "after") + if err != nil { + return CursorPaginationParams{}, err + } + return CursorPaginationParams{ + PerPage: perPage, + After: after, + }, nil +} + +type CursorPaginationParams struct { + PerPage int + After string +} + +// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters. +func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { + if p.PerPage > 100 { + return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage) + } + if p.PerPage < 0 { + return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage) + } + first := int32(p.PerPage) + + var after *string + if p.After != "" { + after = &p.After + } + + return &GraphQLPaginationParams{ + First: &first, + After: after, }, nil } +type GraphQLPaginationParams struct { + First *int32 + After *string +} + +// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters. +// This converts page/perPage to first parameter for GraphQL queries. +// If After is provided, it takes precedence over page-based pagination. +func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { + // Convert to CursorPaginationParams and delegate to avoid duplication + cursor := CursorPaginationParams{ + PerPage: p.PerPage, + After: p.After, + } + return cursor.ToGraphQLParams() +} + func MarshalledTextResult(v any) *mcp.CallToolResult { data, err := json.Marshal(v) if err != nil { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 6353f254d..7f8f29c0d 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -489,8 +489,8 @@ func TestOptionalPaginationParams(t *testing.T) { name: "no pagination parameters, default values", params: map[string]any{}, expected: PaginationParams{ - page: 1, - perPage: 30, + Page: 1, + PerPage: 30, }, expectError: false, }, @@ -500,8 +500,8 @@ func TestOptionalPaginationParams(t *testing.T) { "page": float64(2), }, expected: PaginationParams{ - page: 2, - perPage: 30, + Page: 2, + PerPage: 30, }, expectError: false, }, @@ -511,8 +511,8 @@ func TestOptionalPaginationParams(t *testing.T) { "perPage": float64(50), }, expected: PaginationParams{ - page: 1, - perPage: 50, + Page: 1, + PerPage: 50, }, expectError: false, }, @@ -523,8 +523,8 @@ func TestOptionalPaginationParams(t *testing.T) { "perPage": float64(50), }, expected: PaginationParams{ - page: 2, - perPage: 50, + Page: 2, + PerPage: 50, }, expectError: false, }, From 60a5391d67543ca4ef53370cbb48a01f3702fc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20H=C3=B8st=20Normark?= Date: Tue, 22 Jul 2025 10:24:46 +0200 Subject: [PATCH 03/13] Add tools for sub-issue endpoint (#470) * Create 'add sub-issue' tool * Fix hardcoded API host * Create 'list sub-issues' tool * Create 'remove sub-issue' tool * Fix Test_GetIssue mock data - add missing User field The assertion was already checking User.Login but the mock was incomplete * Create 'reprioritize sub-issue' tool * fixes * use go github pck to add sub-issues * Update to use go github package * update description * update to use go github v73 * lint, docs * refactor: tests to use go-github-mock * add toolsnaps * make RemoveSubIssue use NewGitHubAPIErrorResponse, update docstring * Always include SHA in get_file_contents responses (#676) * fix: Add SHA to get_file_contents while preserving MCP behavior (#595) Enhance get_file_contents to include SHA information without changing the existing MCP server response format. Changes: - Add Contents API call to retrieve SHA before fetching raw content - Include SHA in resourceURI (repo://owner/repo/sha/{SHA}/contents/path) - Add SHA to success messages - Update tests to verify SHA inclusion - Maintain original behavior: text files return raw text, binaries return base64 This preserves backward compatibility while providing SHA information for better file versioning support. Closes #595 * fix: Improve error handling for Contents API response Ensure response body is properly closed even when an error occurs by moving the defer statement before the error check. This prevents potential resource leaks when the Contents API returns an error with a non-nil response. Changes: - Move defer respContents.Body.Close() before error checking - Rename errContents to err for consistency - Add nil check for respContents before attempting to close body This follows Go best practices for handling HTTP responses and prevents potential goroutine/memory leaks. * revert changes to resource URI * use GraphQL API to get file SHA * refactor: mock GQL client instead of getFileSHA function to follow conventions * lint * revert GraphQL --------- Co-authored-by: LuluBeatson * Reorganize README, add dedicated install guides, include policies and governance info for the github server (#695) * Refactor README and add host installation guides, governance docs - Reorganized README for clarity and navigation - Added dedicated installation guides for Claude, Cursor, Windsurf, JetBrains, and more - Clarified contribution guidelines and approval criteria - Added policies and governance documentation * Update README.md * Update README with configuration section for remote GitHub MCP Server * Update MCP access policy description in README Removing coding agent from the policy note, as the GitHub server is unaffected by this policy * Update configuration steps for GitHub Copilot in JetBrains IDEs... ...to reflect changes in accessing settings and configuring MCP. * Update install-other-copilot-ides.md * Update Eclipse MCP support version and configuration steps... ...for GitHub Copilot plugin in installation guide. * Update docs/installation-guides/install-cursor.md * Update docs/installation-guides/install-windsurf.md * Apply suggestion from @tonytrg * Apply suggestion from @tonytrg * Apply suggestion from @tonytrg * Apply suggestion from @tonytrg * Apply suggestion from @tonytrg * Apply suggestion from @tonytrg * Apply suggestion from @tonytrg --------- Co-authored-by: Tony Truong * fix: shorten long tool name for adding pr review comments (#697) * shorten tool name * update function name to match tool name * adjust wording of descriptions * Update installation guide for GitHub MCP Server (#699) * Update installation guide for GitHub MCP Server Removed reference to GitHub.com in the installation guide. The GitHub server is available to Coding Agent by default, without installation needed. * Rename section to 'Install in Other MCP Hosts' Updating title for consistency and adding a link to the "other Copilot IDEs" install guide. * Revise installation guide for Cursor MCP setup Updated installation guide for Cursor with steps clarified, remote server installation, and one-click install deeplinks to open Cursor and add the github server to the config file. * fix: make mcpcurl support "integer" type (#688) - FYI:https://json-schema.org/understanding-json-schema/reference/numeric#integer * Added installation instructions for mcpcurl (#719) * Added installation instructions for mcpcurl * Update cmd/mcpcurl/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add pagination support to GraphQL-based tools (#683) * initial pagination for `ListDiscussions` * redo category id var cast * add GraphQL pagination support for discussion comments and categories * remove pageinfo returns * fix out ref for linter * update docs * move to unified pagination for consensus on params * update docs * refactor pagination handling * update docs * linter fix * conv rest to gql params for safe lint * add nolint * add error handling for perPage value in ToGraphQLParams * refactor pagination error handling * unified params for rest andn graphql and rennamed to be uniform for golang * add 'after' for pagination * update docs * Update pkg/github/discussions.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/github/discussions.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/github/discussions_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * update default page size const * reduce default pagination size from 100 to 30 in discussion tests * update pagination for reverse and total * update pagination to remove from discussions * updated README * improve the `ToGraphQLParams` function --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * shorten param descriptions * fix: resp nil check in error handling in RemoveSubIssue function --------- Co-authored-by: LuluBeatson Co-authored-by: tommaso-moro --- README.md | 28 + pkg/github/__toolsnaps__/add_sub_issue.snap | 39 + pkg/github/__toolsnaps__/list_sub_issues.snap | 38 + .../__toolsnaps__/remove_sub_issue.snap | 35 + .../__toolsnaps__/reprioritize_sub_issue.snap | 43 + pkg/github/issues.go | 403 ++++++++ pkg/github/issues_test.go | 972 ++++++++++++++++++ pkg/github/tools.go | 4 + 8 files changed, 1562 insertions(+) create mode 100644 pkg/github/__toolsnaps__/add_sub_issue.snap create mode 100644 pkg/github/__toolsnaps__/list_sub_issues.snap create mode 100644 pkg/github/__toolsnaps__/remove_sub_issue.snap create mode 100644 pkg/github/__toolsnaps__/reprioritize_sub_issue.snap diff --git a/README.md b/README.md index c06142b76..39aa0157b 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,13 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) +- **add_sub_issue** - Add sub-issue + - `issue_number`: The number of the parent issue (number, required) + - `owner`: Repository owner (string, required) + - `replace_parent`: When true, replaces the sub-issue's current parent issue (boolean, optional) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) + - **assign_copilot_to_issue** - Assign Copilot to issue - `issueNumber`: Issue number (number, required) - `owner`: Repository owner (string, required) @@ -515,6 +522,27 @@ The following sets of tools are available (all are on by default): - `sort`: Sort order (string, optional) - `state`: Filter by state (string, optional) +- **list_sub_issues** - List sub-issues + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default: 1) (number, optional) + - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `repo`: Repository name (string, required) + +- **remove_sub_issue** - Remove sub-issue + - `issue_number`: The number of the parent issue (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) + +- **reprioritize_sub_issue** - Reprioritize sub-issue + - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) + - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) + - `issue_number`: The number of the parent issue (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to reprioritize. ID is not the same as issue number (number, required) + - **search_issues** - Search issues - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap new file mode 100644 index 000000000..2d462bcaf --- /dev/null +++ b/pkg/github/__toolsnaps__/add_sub_issue.snap @@ -0,0 +1,39 @@ +{ + "annotations": { + "title": "Add sub-issue", + "readOnlyHint": false + }, + "description": "Add a sub-issue to a parent issue in a GitHub repository.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the parent issue", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "replace_parent": { + "description": "When true, replaces the sub-issue's current parent issue", + "type": "boolean" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sub_issue_id": { + "description": "The ID of the sub-issue to add. ID is not the same as issue number", + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" + }, + "name": "add_sub_issue" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_sub_issues.snap b/pkg/github/__toolsnaps__/list_sub_issues.snap new file mode 100644 index 000000000..70640e270 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_sub_issues.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "List sub-issues", + "readOnlyHint": true + }, + "description": "List sub-issues for a specific issue in a GitHub repository.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "Issue number", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (default: 1)", + "type": "number" + }, + "per_page": { + "description": "Number of results per page (max 100, default: 30)", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "list_sub_issues" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap new file mode 100644 index 000000000..a29020099 --- /dev/null +++ b/pkg/github/__toolsnaps__/remove_sub_issue.snap @@ -0,0 +1,35 @@ +{ + "annotations": { + "title": "Remove sub-issue", + "readOnlyHint": false + }, + "description": "Remove a sub-issue from a parent issue in a GitHub repository.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the parent issue", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sub_issue_id": { + "description": "The ID of the sub-issue to remove. ID is not the same as issue number", + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" + }, + "name": "remove_sub_issue" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap new file mode 100644 index 000000000..43c258b33 --- /dev/null +++ b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "title": "Reprioritize sub-issue", + "readOnlyHint": false + }, + "description": "Reprioritize a sub-issue to a different position in the parent issue's sub-issue list.", + "inputSchema": { + "properties": { + "after_id": { + "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)", + "type": "number" + }, + "before_id": { + "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)", + "type": "number" + }, + "issue_number": { + "description": "The number of the parent issue", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sub_issue_id": { + "description": "The ID of the sub-issue to reprioritize. ID is not the same as issue number", + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" + }, + "name": "reprioritize_sub_issue" +} \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 80a150aba..f718c37cb 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -9,6 +9,7 @@ import ( "strings" "time" + ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v73/github" @@ -153,6 +154,408 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc } } +// AddSubIssue creates a tool to add a sub-issue to a parent issue. +func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("add_sub_issue", + mcp.WithDescription(t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add sub-issue"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("The number of the parent issue"), + ), + mcp.WithNumber("sub_issue_id", + mcp.Required(), + mcp.Description("The ID of the sub-issue to add. ID is not the same as issue number"), + ), + mcp.WithBoolean("replace_parent", + mcp.Description("When true, replaces the sub-issue's current parent issue"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + subIssueID, err := RequiredInt(request, "sub_issue_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + replaceParent, err := OptionalParam[bool](request, "replace_parent") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + subIssueRequest := github.SubIssueRequest{ + SubIssueID: int64(subIssueID), + ReplaceParent: ToBoolPtr(replaceParent), + } + + subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to add sub-issue", + resp, + err, + ), nil + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to add sub-issue: %s", string(body))), nil + } + + r, err := json.Marshal(subIssue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListSubIssues creates a tool to list sub-issues for a GitHub issue. +func ListSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_sub_issues", + mcp.WithDescription(t("TOOL_LIST_SUB_ISSUES_DESCRIPTION", "List sub-issues for a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_SUB_ISSUES_USER_TITLE", "List sub-issues"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + mcp.WithNumber("page", + mcp.Description("Page number for pagination (default: 1)"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of results per page (max 100, default: 30)"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + opts := &github.IssueListOptions{ + ListOptions: github.ListOptions{ + Page: page, + PerPage: perPage, + }, + } + + subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list sub-issues", + resp, + err, + ), nil + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil + } + + r, err := json.Marshal(subIssues) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } + +} + +// RemoveSubIssue creates a tool to remove a sub-issue from a parent issue. +// Unlike other sub-issue tools, this currently uses a direct HTTP DELETE request +// because of a bug in the go-github library. +// Once the fix is released, this can be updated to use the library method. +// See: https://github.com/google/go-github/pull/3613 +func RemoveSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("remove_sub_issue", + mcp.WithDescription(t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove sub-issue"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("The number of the parent issue"), + ), + mcp.WithNumber("sub_issue_id", + mcp.Required(), + mcp.Description("The ID of the sub-issue to remove. ID is not the same as issue number"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + subIssueID, err := RequiredInt(request, "sub_issue_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Create the request body + requestBody := map[string]interface{}{ + "sub_issue_id": subIssueID, + } + reqBodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + // Create the HTTP request + url := fmt.Sprintf("%srepos/%s/%s/issues/%d/sub_issue", + client.BaseURL.String(), owner, repo, issueNumber) + req, err := http.NewRequestWithContext(ctx, "DELETE", url, strings.NewReader(string(reqBodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + httpClient := client.Client() // Use authenticated GitHub client + resp, err := httpClient.Do(req) + if err != nil { + var ghResp *github.Response + if resp != nil { + ghResp = &github.Response{Response: resp} + } + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to remove sub-issue", + ghResp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil + } + + // Parse and re-marshal to ensure consistent formatting + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ReprioritizeSubIssue creates a tool to reprioritize a sub-issue to a different position in the parent list. +func ReprioritizeSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("reprioritize_sub_issue", + mcp.WithDescription(t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize a sub-issue to a different position in the parent issue's sub-issue list.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize sub-issue"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("The number of the parent issue"), + ), + mcp.WithNumber("sub_issue_id", + mcp.Required(), + mcp.Description("The ID of the sub-issue to reprioritize. ID is not the same as issue number"), + ), + mcp.WithNumber("after_id", + mcp.Description("The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)"), + ), + mcp.WithNumber("before_id", + mcp.Description("The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + subIssueID, err := RequiredInt(request, "sub_issue_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Handle optional positioning parameters + afterID, err := OptionalIntParam(request, "after_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + beforeID, err := OptionalIntParam(request, "before_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Validate that either after_id or before_id is specified, but not both + if afterID == 0 && beforeID == 0 { + return mcp.NewToolResultError("either after_id or before_id must be specified"), nil + } + if afterID != 0 && beforeID != 0 { + return mcp.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + subIssueRequest := github.SubIssueRequest{ + SubIssueID: int64(subIssueID), + } + + if afterID != 0 { + afterIDInt64 := int64(afterID) + subIssueRequest.AfterID = &afterIDInt64 + } + if beforeID != 0 { + beforeIDInt64 := int64(beforeID) + subIssueRequest.BeforeID = &beforeIDInt64 + } + + subIssue, resp, err := client.SubIssue.Reprioritize(ctx, owner, repo, int64(issueNumber), subIssueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to reprioritize sub-issue", + resp, + err, + ), nil + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to reprioritize sub-issue: %s", string(body))), nil + } + + r, err := json.Marshal(subIssue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // SearchIssues creates a tool to search for issues. func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_issues", diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 146259477..2bdb89b06 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -38,6 +38,9 @@ func Test_GetIssue(t *testing.T) { Body: github.Ptr("This is a test issue"), State: github.Ptr("open"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, } tests := []struct { @@ -111,6 +114,9 @@ func Test_GetIssue(t *testing.T) { assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) }) } } @@ -1629,3 +1635,969 @@ func TestAssignCopilotToIssue(t *testing.T) { }) } } + +func Test_AddSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := AddSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_sub_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") + assert.Contains(t, tool.InputSchema.Properties, "replace_parent") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"}) + + // Setup mock issue for success case (matches GitHub API response format) + mockIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("This is the parent issue with a sub-issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("enhancement"), + Color: github.Ptr("84b6eb"), + Description: github.Ptr("New feature or request"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issue addition with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "replace_parent": true, + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful sub-issue addition with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(456), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful sub-issue addition with replace_parent false", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(789), + "replace_parent": false, + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "validation failed - sub-issue cannot be parent of itself", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: sub_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := AddSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} + +func Test_ListSubIssues(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListSubIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_sub_issues", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock sub-issues for success case + mockSubIssues := []*github.Issue{ + { + Number: github.Ptr(123), + Title: github.Ptr("Sub-issue 1"), + Body: github.Ptr("This is the first sub-issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + User: &github.User{ + Login: github.Ptr("user1"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("bug"), + Color: github.Ptr("d73a4a"), + Description: github.Ptr("Something isn't working"), + }, + }, + }, + { + Number: github.Ptr(124), + Title: github.Ptr("Sub-issue 2"), + Body: github.Ptr("This is the second sub-issue"), + State: github.Ptr("closed"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + Assignees: []*github.User{ + {Login: github.Ptr("assignee1")}, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedSubIssues []*github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issues listing with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockSubIssues, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedSubIssues: mockSubIssues, + }, + { + name: "successful sub-issues listing with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSubIssues), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "per_page": float64(10), + }, + expectError: false, + expectedSubIssues: mockSubIssues, + }, + { + name: "successful sub-issues listing with empty result", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + []*github.Issue{}, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedSubIssues: []*github.Issue{}, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to list sub-issues", + }, + { + name: "repository not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "nonexistent", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to list sub-issues", + }, + { + name: "sub-issues feature gone/deprecated", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to list sub-issues", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter issue_number", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedErrMsg: "missing required parameter: issue_number", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListSubIssues(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedSubIssues []*github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues) + require.NoError(t, err) + + assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues)) + for i, subIssue := range returnedSubIssues { + if i < len(tc.expectedSubIssues) { + assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) + assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) + assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State) + assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL) + assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login) + + if tc.expectedSubIssues[i].Body != nil { + assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) + } + } + } + }) + } +} + +func Test_RemoveSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := RemoveSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "remove_sub_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"}) + + // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) + mockIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("This is the parent issue after sub-issue removal"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("enhancement"), + Color: github.Ptr("84b6eb"), + Description: github.Ptr("New feature or request"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issue removal", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "bad request - invalid sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(-1), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "repository not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "nonexistent", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: sub_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RemoveSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} + +func Test_ReprioritizeSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ReprioritizeSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "reprioritize_sub_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") + assert.Contains(t, tool.InputSchema.Properties, "after_id") + assert.Contains(t, tool.InputSchema.Properties, "before_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"}) + + // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) + mockIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("This is the parent issue with reprioritized sub-issues"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("enhancement"), + Color: github.Ptr("84b6eb"), + Description: github.Ptr("New feature or request"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful reprioritization with after_id", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful reprioritization with before_id", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "before_id": float64(789), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "validation error - neither after_id nor before_id specified", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "either after_id or before_id must be specified", + }, + { + name: "validation error - both after_id and before_id specified", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + "before_id": float64(789), + }, + expectError: false, + expectedErrMsg: "only one of after_id or before_id should be specified, not both", + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(999), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "validation failed - positioning sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "service unavailable", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "before_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "missing required parameter: sub_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ReprioritizeSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index bd349171d..e01b7cc40 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -53,12 +53,16 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), + toolsets.NewServerTool(ListSubIssues(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)), toolsets.NewServerTool(AddIssueComment(getClient, t)), toolsets.NewServerTool(UpdateIssue(getClient, t)), toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), + toolsets.NewServerTool(AddSubIssue(getClient, t)), + toolsets.NewServerTool(RemoveSubIssue(getClient, t)), + toolsets.NewServerTool(ReprioritizeSubIssue(getClient, t)), ).AddPrompts(toolsets.NewServerPrompt(AssignCodingAgentPrompt(t))) users := toolsets.NewToolset("users", "GitHub User related tools"). AddReadTools( From 7a9bc91185c8552336b95a7fbbb2d37d63e0ce0a Mon Sep 17 00:00:00 2001 From: vladislav doster Date: Wed, 23 Jul 2025 03:19:51 -0500 Subject: [PATCH 04/13] docs: fix spacing in testing.md (#734) - remove extraneous space --- docs/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index dbdc3e080..226660e9d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,7 +22,7 @@ This project uses a combination of unit tests and end-to-end (e2e) tests to ensu ## toolsnaps: Tool Schema Snapshots - The `toolsnaps` utility ensures that the JSON schema for each tool does not change unexpectedly. -- Snapshots are stored in `__toolsnaps__/*.snap` files , where `*` represents the name of the tool +- Snapshots are stored in `__toolsnaps__/*.snap` files, where `*` represents the name of the tool - When running tests, the current tool schema is compared to the snapshot. If there is a difference, the test will fail and show a diff. - If you intentionally change a tool's schema, update the snapshots by running tests with the environment variable: `UPDATE_TOOLSNAPS=true go test ./...` - In CI (when `GITHUB_ACTIONS=true`), missing snapshots will cause a test failure to ensure snapshots are always From a9395651605765afe1f667516911901f13843ace Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:26:00 +0200 Subject: [PATCH 05/13] feat: list_discussions sort by updatedAt & createdAt, return updatedAt and author (#690) * added updatedAt and Author (aka User) login to query and payload * added initial support for orderby and direction * sort by created at instead of updated at by default * remove unused code * refactor to map to most suitable query based on user inputs at runtime * updated readme with new description * restore original categoryID code, simplify vars management * quick fix * update tests to account for recent changes (author login, updated at date) * use switch statement for better readability * remove comment * linting * refactored logic, simplified switch statement * linting * use original queries from discussions list tool for testing * linting * remove logging * Complete merge by re-introducing pagination to ListDiscussions * fix unit tests * refactor: less repetitive interface --------- Co-authored-by: LuluBeatson Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com> --- README.md | 2 + pkg/github/discussions.go | 306 ++++++++++++++++++--------------- pkg/github/discussions_test.go | 241 ++++++++++++++++++++++++-- 3 files changed, 396 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 39aa0157b..58f0e897e 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,8 @@ The following sets of tools are available (all are on by default): - **list_discussions** - List discussions - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) + - `direction`: Order direction. (string, optional) + - `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional) - `owner`: Repository owner (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 2b8ccfb0b..fce07ecdb 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -15,6 +15,108 @@ import ( const DefaultGraphQLPageSize = 30 +// Common interface for all discussion query types +type DiscussionQueryResult interface { + GetDiscussionFragment() DiscussionFragment +} + +// Implement the interface for all query types +func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +type DiscussionFragment struct { + Nodes []NodeFragment + PageInfo PageInfoFragment + TotalCount githubv4.Int +} + +type NodeFragment struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Author struct { + Login githubv4.String + } + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` +} + +type PageInfoFragment struct { + HasNextPage bool + HasPreviousPage bool + StartCursor githubv4.String + EndCursor githubv4.String +} + +type BasicNoOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type BasicWithOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type WithCategoryAndOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type WithCategoryNoOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { + return &github.Discussion{ + Number: github.Ptr(int(fragment.Number)), + Title: github.Ptr(string(fragment.Title)), + HTMLURL: github.Ptr(string(fragment.URL)), + CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, + UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, + User: &github.User{ + Login: github.Ptr(string(fragment.Author.Login)), + }, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(fragment.Category.Name)), + }, + } +} + +func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { + if categoryID != nil && useOrdering { + return &WithCategoryAndOrder{} + } + if categoryID != nil && !useOrdering { + return &WithCategoryNoOrder{} + } + if categoryID == nil && useOrdering { + return &BasicWithOrder{} + } + return &BasicNoOrder{} +} + func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_discussions", mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")), @@ -33,10 +135,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.WithString("category", mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), ), + mcp.WithString("orderBy", + mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), + mcp.Enum("CREATED_AT", "UPDATED_AT"), + ), + mcp.WithString("direction", + mcp.Description("Order direction."), + mcp.Enum("ASC", "DESC"), + ), WithCursorPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Required params owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -46,12 +155,21 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp return mcp.NewToolResultError(err.Error()), nil } - // Optional params category, err := OptionalParam[string](request, "category") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + orderBy, err := OptionalParam[string](request, "orderBy") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(request) if err != nil { @@ -67,155 +185,69 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } - // If category filter is specified, use it as the category ID for server-side filtering var categoryID *githubv4.ID if category != "" { id := githubv4.ID(category) categoryID = &id } - var out []byte - - var discussions []*github.Discussion - if categoryID != nil { - // Query with category filter (server-side filtering) - var query struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - PageInfo struct { - HasNextPage bool - HasPreviousPage bool - StartCursor string - EndCursor string - } - TotalCount int - } `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "categoryId": *categoryID, - "first": githubv4.Int(*paginationParams.First), - } - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - vars["after"] = (*githubv4.String)(nil) - } - if err := client.Query(ctx, &query, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Map nodes to GitHub Discussion objects - for _, n := range query.Repository.Discussions.Nodes { - di := &github.Discussion{ - Number: github.Ptr(int(n.Number)), - Title: github.Ptr(string(n.Title)), - HTMLURL: github.Ptr(string(n.URL)), - CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time}, - DiscussionCategory: &github.DiscussionCategory{ - Name: github.Ptr(string(n.Category.Name)), - }, - } - discussions = append(discussions, di) - } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) + } - // Create response with pagination info - response := map[string]interface{}{ - "discussions": discussions, - "pageInfo": map[string]interface{}{ - "hasNextPage": query.Repository.Discussions.PageInfo.HasNextPage, - "hasPreviousPage": query.Repository.Discussions.PageInfo.HasPreviousPage, - "startCursor": query.Repository.Discussions.PageInfo.StartCursor, - "endCursor": query.Repository.Discussions.PageInfo.EndCursor, - }, - "totalCount": query.Repository.Discussions.TotalCount, - } + // this is an extra check in case the tool description is misinterpreted, because + // we shouldn't use ordering unless both a 'field' and 'direction' are provided + useOrdering := orderBy != "" && direction != "" + if useOrdering { + vars["orderByField"] = githubv4.DiscussionOrderField(orderBy) + vars["orderByDirection"] = githubv4.OrderDirection(direction) + } - out, err = json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal discussions: %w", err) - } - } else { - // Query without category filter - var query struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - PageInfo struct { - HasNextPage bool - HasPreviousPage bool - StartCursor string - EndCursor string - } - TotalCount int - } `graphql:"discussions(first: $first, after: $after)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "first": githubv4.Int(*paginationParams.First), - } - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - vars["after"] = (*githubv4.String)(nil) - } - if err := client.Query(ctx, &query, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if categoryID != nil { + vars["categoryId"] = *categoryID + } - // Map nodes to GitHub Discussion objects - for _, n := range query.Repository.Discussions.Nodes { - di := &github.Discussion{ - Number: github.Ptr(int(n.Number)), - Title: github.Ptr(string(n.Title)), - HTMLURL: github.Ptr(string(n.URL)), - CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time}, - DiscussionCategory: &github.DiscussionCategory{ - Name: github.Ptr(string(n.Category.Name)), - }, - } - discussions = append(discussions, di) - } + discussionQuery := getQueryType(useOrdering, categoryID) + if err := client.Query(ctx, discussionQuery, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Create response with pagination info - response := map[string]interface{}{ - "discussions": discussions, - "pageInfo": map[string]interface{}{ - "hasNextPage": query.Repository.Discussions.PageInfo.HasNextPage, - "hasPreviousPage": query.Repository.Discussions.PageInfo.HasPreviousPage, - "startCursor": query.Repository.Discussions.PageInfo.StartCursor, - "endCursor": query.Repository.Discussions.PageInfo.EndCursor, - }, - "totalCount": query.Repository.Discussions.TotalCount, + // Extract and convert all discussion nodes using the common interface + var discussions []*github.Discussion + var pageInfo PageInfoFragment + var totalCount githubv4.Int + if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok { + fragment := queryResult.GetDiscussionFragment() + for _, node := range fragment.Nodes { + discussions = append(discussions, fragmentToDiscussion(node)) } + pageInfo = fragment.PageInfo + totalCount = fragment.TotalCount + } - out, err = json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal discussions: %w", err) - } + // Create response with pagination info + response := map[string]interface{}{ + "discussions": discussions, + "pageInfo": map[string]interface{}{ + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), + }, + "totalCount": totalCount, } + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussions: %w", err) + } return mcp.NewToolResultText(string(out)), nil } } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index e2e3d99ed..aefaf2f8c 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -17,14 +17,58 @@ import ( var ( discussionsGeneral = []map[string]any{ - {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, - {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, } discussionsAll = []map[string]any{ - {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, - {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}}, - {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, + { + "number": 1, + "title": "Discussion 1 title", + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "url": "https://github.com/owner/repo/discussions/1", + "category": map[string]any{"name": "General"}, + }, + { + "number": 2, + "title": "Discussion 2 title", + "createdAt": "2023-02-01T00:00:00Z", + "updatedAt": "2023-02-01T00:00:00Z", + "author": map[string]any{"login": "user2"}, + "url": "https://github.com/owner/repo/discussions/2", + "category": map[string]any{"name": "Questions"}, + }, + { + "number": 3, + "title": "Discussion 3 title", + "createdAt": "2023-03-01T00:00:00Z", + "updatedAt": "2023-03-01T00:00:00Z", + "author": map[string]any{"login": "user3"}, + "url": "https://github.com/owner/repo/discussions/3", + "category": map[string]any{"name": "General"}, + }, + } + + // Ordered mock responses + discussionsOrderedCreatedAsc = []map[string]any{ + discussionsAll[0], // Discussion 1 (created 2023-01-01) + discussionsAll[1], // Discussion 2 (created 2023-02-01) + discussionsAll[2], // Discussion 3 (created 2023-03-01) + } + + discussionsOrderedUpdatedDesc = []map[string]any{ + discussionsAll[2], // Discussion 3 (updated 2023-03-01) + discussionsAll[1], // Discussion 2 (updated 2023-02-01) + discussionsAll[0], // Discussion 1 (updated 2023-01-01) } + + // only 'General' category discussions ordered by created date descending + discussionsGeneralOrderedDesc = []map[string]any{ + discussionsGeneral[1], // Discussion 3 (created 2023-03-01) + discussionsGeneral[0], // Discussion 1 (created 2023-01-01) + } + mockResponseListAll = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ @@ -53,24 +97,62 @@ var ( }, }, }) + mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsOrderedCreatedAsc, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, + }, + }) + mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsOrderedUpdatedDesc, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, + }, + }) + mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsGeneralOrderedDesc, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") ) func Test_ListDiscussions(t *testing.T) { mockClient := githubv4.NewClient(nil) - // Verify tool definition and schema toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) assert.Equal(t, "list_discussions", toolDef.Name) assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") + assert.Contains(t, toolDef.InputSchema.Properties, "direction") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) - // Use exact string queries that match implementation output (from error messages) - qDiscussions := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - - qDiscussionsFiltered := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - // Variables matching what GraphQL receives after JSON marshaling/unmarshaling varsListAll := map[string]interface{}{ "owner": "owner", @@ -94,12 +176,41 @@ func Test_ListDiscussions(t *testing.T) { "after": (*string)(nil), } + varsOrderByCreatedAsc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "CREATED_AT", + "orderByDirection": "ASC", + "first": float64(30), + "after": (*string)(nil), + } + + varsOrderByUpdatedDesc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "UPDATED_AT", + "orderByDirection": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsCategoryWithOrder := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "orderByField": "CREATED_AT", + "orderByDirection": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + tests := []struct { name string reqParams map[string]interface{} expectError bool errContains string expectedCount int + verifyOrder func(t *testing.T, discussions []*github.Discussion) }{ { name: "list all discussions without category filter", @@ -120,6 +231,80 @@ func Test_ListDiscussions(t *testing.T) { expectError: false, expectedCount: 2, // Only General discussions (matching the category ID) }, + { + name: "order by created at ascending", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderBy": "CREATED_AT", + "direction": "ASC", + }, + expectError: false, + expectedCount: 3, + verifyOrder: func(t *testing.T, discussions []*github.Discussion) { + // Verify discussions are ordered by created date ascending + require.Len(t, discussions, 3) + assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)") + assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)") + assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)") + }, + }, + { + name: "order by updated at descending", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderBy": "UPDATED_AT", + "direction": "DESC", + }, + expectError: false, + expectedCount: 3, + verifyOrder: func(t *testing.T, discussions []*github.Discussion) { + // Verify discussions are ordered by updated date descending + require.Len(t, discussions, 3) + assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)") + assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)") + assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)") + }, + }, + { + name: "filter by category with order", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "category": "DIC_kwDOABC123", + "orderBy": "CREATED_AT", + "direction": "DESC", + }, + expectError: false, + expectedCount: 2, + verifyOrder: func(t *testing.T, discussions []*github.Discussion) { + // Verify only General discussions, ordered by created date descending + require.Len(t, discussions, 2) + assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)") + assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)") + }, + }, + { + name: "order by without direction (should not use ordering)", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderBy": "CREATED_AT", + }, + expectError: false, + expectedCount: 3, + }, + { + name: "direction without order by (should not use ordering)", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "direction": "DESC", + }, + expectError: false, + expectedCount: 3, + }, { name: "repository not found error", reqParams: map[string]interface{}{ @@ -131,21 +316,40 @@ func Test_ListDiscussions(t *testing.T) { }, } + // Define the actual query strings that match the implementation + qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var httpClient *http.Client switch tc.name { case "list all discussions without category filter": - // Simple case - no category filter - matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll) + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "filter by category ID": - // Simple case - category filter using category ID directly - matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral) + matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "order by created at ascending": + matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "order by updated at descending": + matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by category with order": + matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "order by without direction (should not use ordering)": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "direction without order by (should not use ordering)": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "repository not found error": - matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound) + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) httpClient = githubv4mock.NewMockedHTTPClient(matcher) } @@ -179,6 +383,11 @@ func Test_ListDiscussions(t *testing.T) { assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) + // Verify order if verifyOrder function is provided + if tc.verifyOrder != nil { + tc.verifyOrder(t, response.Discussions) + } + // Verify that all returned discussions have a category if filtered if _, hasCategory := tc.reqParams["category"]; hasCategory { for _, discussion := range response.Discussions { From efef8ae27a1d62c38c8f760ca38a65845efd3576 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 24 Jul 2025 12:35:35 +0200 Subject: [PATCH 06/13] Changed q to query in search (#740) --- README.md | 2 +- pkg/github/__toolsnaps__/search_code.snap | 4 ++-- pkg/github/search.go | 4 ++-- pkg/github/search_test.go | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 58f0e897e..be9288e40 100644 --- a/README.md +++ b/README.md @@ -838,7 +838,7 @@ The following sets of tools are available (all are on by default): - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `q`: Search query using GitHub code search syntax (string, required) + - `query`: Search query using GitHub code search syntax (string, required) - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index c85d6674d..e341f3e38 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -25,7 +25,7 @@ "minimum": 1, "type": "number" }, - "q": { + "query": { "description": "Search query using GitHub code search syntax", "type": "string" }, @@ -35,7 +35,7 @@ } }, "required": [ - "q" + "query" ], "type": "object" }, diff --git a/pkg/github/search.go b/pkg/github/search.go index b11bb3bbc..476ac0151 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -83,7 +83,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("q", + mcp.WithString("query", mcp.Required(), mcp.Description("Search query using GitHub code search syntax"), ), @@ -97,7 +97,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "q") + query, err := RequiredParam[string](request, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 21f7a0ca2..9ea8e71ec 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -173,12 +173,12 @@ func Test_SearchCode(t *testing.T) { assert.Equal(t, "search_code", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "q") + assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) // Setup mock search results mockSearchResult := &github.CodeSearchResult{ @@ -227,7 +227,7 @@ func Test_SearchCode(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "q": "fmt.Println language:go", + "query": "fmt.Println language:go", "sort": "indexed", "order": "desc", "page": float64(1), @@ -251,7 +251,7 @@ func Test_SearchCode(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "q": "fmt.Println language:go", + "query": "fmt.Println language:go", }, expectError: false, expectedResult: mockSearchResult, @@ -268,7 +268,7 @@ func Test_SearchCode(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "q": "invalid:query", + "query": "invalid:query", }, expectError: true, expectedErrMsg: "failed to search code", From d5e1f48728dd85ecdb7aa12246c2f6e9a9eb3a8c Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 29 Jul 2025 10:58:06 +0100 Subject: [PATCH 07/13] Add updating draft state to `update_pull_request` tool (#774) * initial impl of pull request draft state update * appease linter * update README * add nosec * fixed err return type for json marshalling * add gql test --- README.md | 1 + .../__toolsnaps__/update_pull_request.snap | 4 + pkg/github/pullrequests.go | 146 ++++++++++-- pkg/github/pullrequests_test.go | 224 +++++++++++++++++- pkg/github/tools.go | 2 +- 5 files changed, 348 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index be9288e40..34edc7b33 100644 --- a/README.md +++ b/README.md @@ -736,6 +736,7 @@ The following sets of tools are available (all are on by default): - **update_pull_request** - Edit pull request - `base`: New base branch name (string, optional) - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number to update (number, required) diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index 765983afd..c44d8afa0 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -14,6 +14,10 @@ "description": "New description", "type": "string" }, + "draft": { + "description": "Mark pull request as draft (true) or ready for review (false)", + "type": "boolean" + }, "maintainer_can_modify": { "description": "Allow maintainer edits", "type": "boolean" diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 47b7c6bd2..f380843b0 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -203,7 +203,7 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu } // UpdatePullRequest creates a tool to update an existing pull request. -func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { return mcp.NewTool("update_pull_request", mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ @@ -232,6 +232,9 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu mcp.Description("New state"), mcp.Enum("open", "closed"), ), + mcp.WithBoolean("draft", + mcp.Description("Mark pull request as draft (true) or ready for review (false)"), + ), mcp.WithString("base", mcp.Description("New base branch name"), ), @@ -253,74 +256,165 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(err.Error()), nil } - // Build the update struct only with provided fields + draftProvided := request.GetArguments()["draft"] != nil + var draftValue bool + if draftProvided { + draftValue, err = OptionalParam[bool](request, "draft") + if err != nil { + return nil, err + } + } + update := &github.PullRequest{} - updateNeeded := false + restUpdateNeeded := false if title, ok, err := OptionalParamOK[string](request, "title"); err != nil { return mcp.NewToolResultError(err.Error()), nil } else if ok { update.Title = github.Ptr(title) - updateNeeded = true + restUpdateNeeded = true } if body, ok, err := OptionalParamOK[string](request, "body"); err != nil { return mcp.NewToolResultError(err.Error()), nil } else if ok { update.Body = github.Ptr(body) - updateNeeded = true + restUpdateNeeded = true } if state, ok, err := OptionalParamOK[string](request, "state"); err != nil { return mcp.NewToolResultError(err.Error()), nil } else if ok { update.State = github.Ptr(state) - updateNeeded = true + restUpdateNeeded = true } if base, ok, err := OptionalParamOK[string](request, "base"); err != nil { return mcp.NewToolResultError(err.Error()), nil } else if ok { update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} - updateNeeded = true + restUpdateNeeded = true } if maintainerCanModify, ok, err := OptionalParamOK[bool](request, "maintainer_can_modify"); err != nil { return mcp.NewToolResultError(err.Error()), nil } else if ok { update.MaintainerCanModify = github.Ptr(maintainerCanModify) - updateNeeded = true + restUpdateNeeded = true } - if !updateNeeded { + if !restUpdateNeeded && !draftProvided { return mcp.NewToolResultError("No update parameters provided."), nil } + if restUpdateNeeded { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update pull request", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to update pull request: %s", string(body))), nil + } + } + + if draftProvided { + gqlClient, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + var prQuery struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers + }) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil + } + + currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft) + + if currentIsDraft != draftValue { + if draftValue { + // Convert to draft + var mutation struct { + ConvertPullRequestToDraft struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"convertPullRequestToDraft(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ + PullRequestID: prQuery.Repository.PullRequest.ID, + }, nil) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil + } + } else { + // Mark as ready for review + var mutation struct { + MarkPullRequestReadyForReview struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"markPullRequestReadyForReview(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ + PullRequestID: prQuery.Repository.PullRequest.ID, + }, nil) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil + } + } + } + } + client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return nil, err } - pr, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) + + finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to update pull request", - resp, - err, - ), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() } - return mcp.NewToolResultError(fmt.Sprintf("failed to update pull request: %s", string(body))), nil - } + }() - r, err := json.Marshal(pr) + r, err := json.Marshal(finalPR) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil } return mcp.NewToolResultText(string(r)), nil diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 42fd5bf03..179458115 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -137,7 +137,7 @@ func Test_GetPullRequest(t *testing.T) { func Test_UpdatePullRequest(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "update_pull_request", tool.Name) @@ -145,6 +145,7 @@ func Test_UpdatePullRequest(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "draft") assert.Contains(t, tool.InputSchema.Properties, "title") assert.Contains(t, tool.InputSchema.Properties, "body") assert.Contains(t, tool.InputSchema.Properties, "state") @@ -160,6 +161,7 @@ func Test_UpdatePullRequest(t *testing.T) { HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), Body: github.Ptr("Updated test PR body."), MaintainerCanModify: github.Ptr(false), + Draft: github.Ptr(false), Base: &github.PullRequestBranch{ Ref: github.Ptr("develop"), }, @@ -194,6 +196,10 @@ func Test_UpdatePullRequest(t *testing.T) { mockResponse(t, http.StatusOK, mockUpdatedPR), ), ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockUpdatedPR, + ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -218,6 +224,10 @@ func Test_UpdatePullRequest(t *testing.T) { mockResponse(t, http.StatusOK, mockClosedPR), ), ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockClosedPR, + ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -228,6 +238,31 @@ func Test_UpdatePullRequest(t *testing.T) { expectError: false, expectedPR: mockClosedPR, }, + { + name: "successful PR update (title only)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsByOwnerByRepoByPullNumber, + expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockUpdatedPR, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated Test PR Title", + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, { name: "no update parameters provided", mockedClient: mock.NewMockedHTTPClient(), // No API call expected @@ -266,7 +301,7 @@ func Test_UpdatePullRequest(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := UpdatePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := UpdatePullRequest(stubGetClientFn(client), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -316,6 +351,191 @@ func Test_UpdatePullRequest(t *testing.T) { } } +func Test_UpdatePullRequest_Draft(t *testing.T) { + // Setup mock PR for success case + mockUpdatedPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR Title"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Body: github.Ptr("Test PR body."), + MaintainerCanModify: github.Ptr(false), + Draft: github.Ptr(false), // Updated to ready for review + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + }{ + { + name: "successful draft update to ready for review", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": true, // Current state is draft + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + MarkPullRequestReadyForReview struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"markPullRequestReadyForReview(input: $input)"` + }{}, + githubv4.MarkPullRequestReadyForReviewInput{ + PullRequestID: "PR_kwDOA0xdyM50BPaO", + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "markPullRequestReadyForReview": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": false, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "draft": false, + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, + { + name: "successful convert pull request to draft", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": false, // Current state is draft + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ConvertPullRequestToDraft struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"convertPullRequestToDraft(input: $input)"` + }{}, + githubv4.ConvertPullRequestToDraftInput{ + PullRequestID: "PR_kwDOA0xdyM50BPaO", + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "convertPullRequestToDraft": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": true, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "draft": true, + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // For draft-only tests, we need to mock both GraphQL and the final REST GET call + restClient := github.NewClient(mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockUpdatedPR, + ), + )) + gqlClient := githubv4.NewClient(tc.mockedClient) + + _, handler := UpdatePullRequest(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError || tc.expectedErrMsg != "" { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the successful result + var returnedPR github.PullRequest + err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + require.NoError(t, err) + assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) + if tc.expectedPR.Draft != nil { + assert.Equal(t, *tc.expectedPR.Draft, *returnedPR.Draft) + } + }) + } +} + func Test_ListPullRequests(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index e01b7cc40..caa4f9cfe 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -87,7 +87,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(MergePullRequest(getClient, t)), toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)), toolsets.NewServerTool(CreatePullRequest(getClient, t)), - toolsets.NewServerTool(UpdatePullRequest(getClient, t)), + toolsets.NewServerTool(UpdatePullRequest(getClient, getGQLClient, t)), toolsets.NewServerTool(RequestCopilotReview(getClient, t)), // Reviews From 89e3afdb4b0dabd2e0d440c3f01a7a2fbbdb33a3 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:17:03 +0200 Subject: [PATCH 08/13] Add support for org-level discussions in list_discussions tool (#775) * make repo optional, and default to .github when not provided. improve tool description * autogen * update tests * small copy paste error fixes --- README.md | 2 +- pkg/github/discussions.go | 12 ++++-- pkg/github/discussions_test.go | 77 +++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 34edc7b33..00bb81910 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ The following sets of tools are available (all are on by default): - `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional) - `owner`: Repository owner (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) + - `repo`: Repository name. If not provided, discussions will be queried at the organisation level. (string, optional) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index fce07ecdb..905a1b709 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -119,7 +119,7 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_discussions", - mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")), + mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), ReadOnlyHint: ToBoolPtr(true), @@ -129,8 +129,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.Description("Repository owner"), ), mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), + mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), ), mcp.WithString("category", mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), @@ -150,10 +149,15 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := RequiredParam[string](request, "repo") + repo, err := OptionalParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + // when not provided, default to the .github repository + // this will query discussions at the organisation level + if repo == "" { + repo = ".github" + } category, err := OptionalParam[string](request, "category") if err != nil { diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index aefaf2f8c..1fa90b403 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -50,6 +50,46 @@ var ( }, } + discussionsOrgLevel = []map[string]any{ + { + "number": 1, + "title": "Org Discussion 1 - Community Guidelines", + "createdAt": "2023-01-15T00:00:00Z", + "updatedAt": "2023-01-15T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/1", + "category": map[string]any{"name": "Announcements"}, + }, + { + "number": 2, + "title": "Org Discussion 2 - Roadmap 2023", + "createdAt": "2023-02-20T00:00:00Z", + "updatedAt": "2023-02-20T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/2", + "category": map[string]any{"name": "General"}, + }, + { + "number": 3, + "title": "Org Discussion 3 - Roadmap 2024", + "createdAt": "2023-02-20T00:00:00Z", + "updatedAt": "2023-02-20T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/3", + "category": map[string]any{"name": "General"}, + }, + { + "number": 4, + "title": "Org Discussion 4 - Roadmap 2025", + "createdAt": "2023-02-20T00:00:00Z", + "updatedAt": "2023-02-20T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/4", + "category": map[string]any{"name": "General"}, + }, + + } + // Ordered mock responses discussionsOrderedCreatedAsc = []map[string]any{ discussionsAll[0], // Discussion 1 (created 2023-01-01) @@ -139,6 +179,22 @@ var ( }, }, }) + + mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsOrgLevel, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 4, + }, + }, + }) + mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") ) @@ -151,7 +207,7 @@ func Test_ListDiscussions(t *testing.T) { assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") assert.Contains(t, toolDef.InputSchema.Properties, "direction") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) // Variables matching what GraphQL receives after JSON marshaling/unmarshaling varsListAll := map[string]interface{}{ @@ -204,6 +260,13 @@ func Test_ListDiscussions(t *testing.T) { "after": (*string)(nil), } + varsOrgLevel := map[string]interface{}{ + "owner": "owner", + "repo": ".github", // This is what gets set when repo is not provided + "first": float64(30), + "after": (*string)(nil), + } + tests := []struct { name string reqParams map[string]interface{} @@ -314,6 +377,15 @@ func Test_ListDiscussions(t *testing.T) { expectError: true, errContains: "repository not found", }, + { + name: "list org-level discussions (no repo provided)", + reqParams: map[string]interface{}{ + "owner": "owner", + // repo is not provided, it will default to ".github" + }, + expectError: false, + expectedCount: 4, + }, } // Define the actual query strings that match the implementation @@ -351,6 +423,9 @@ func Test_ListDiscussions(t *testing.T) { case "repository not found error": matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "list org-level discussions (no repo provided)": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) } gqlClient := githubv4.NewClient(httpClient) From 771d7b411fdb7746d18cc55d3828ee71de1df47a Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 30 Jul 2025 11:39:58 +0100 Subject: [PATCH 09/13] Add initial support for multi-tool workflows (#685) * initial workflows * fixing tabs * remove unused SecurityAlertWorkflowPrompt and RepositorySetupWorkflowPrompt * add workflow prompt for creating issue and assigning to copilot * Update pkg/github/workflow_prompts.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * remove notif triage * remove code inv workflow tool * rm another --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/github/tools.go | 5 ++- pkg/github/workflow_prompts.go | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 pkg/github/workflow_prompts.go diff --git a/pkg/github/tools.go b/pkg/github/tools.go index caa4f9cfe..499defc86 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -63,7 +63,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(AddSubIssue(getClient, t)), toolsets.NewServerTool(RemoveSubIssue(getClient, t)), toolsets.NewServerTool(ReprioritizeSubIssue(getClient, t)), - ).AddPrompts(toolsets.NewServerPrompt(AssignCodingAgentPrompt(t))) + ).AddPrompts( + toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), + toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), + ) users := toolsets.NewToolset("users", "GitHub User related tools"). AddReadTools( toolsets.NewServerTool(SearchUsers(getClient, t)), diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go new file mode 100644 index 000000000..8a9545a42 --- /dev/null +++ b/pkg/github/workflow_prompts.go @@ -0,0 +1,77 @@ +package github + +import ( + "context" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// IssueToFixWorkflowPrompt provides a guided workflow for creating an issue and then generating a PR to fix it +func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { + return mcp.NewPrompt("IssueToFixWorkflow", + mcp.WithPromptDescription(t("PROMPT_ISSUE_TO_FIX_WORKFLOW_DESCRIPTION", "Create an issue for a problem and then generate a pull request to fix it")), + mcp.WithArgument("owner", mcp.ArgumentDescription("Repository owner"), mcp.RequiredArgument()), + mcp.WithArgument("repo", mcp.ArgumentDescription("Repository name"), mcp.RequiredArgument()), + mcp.WithArgument("title", mcp.ArgumentDescription("Issue title"), mcp.RequiredArgument()), + mcp.WithArgument("description", mcp.ArgumentDescription("Issue description"), mcp.RequiredArgument()), + mcp.WithArgument("labels", mcp.ArgumentDescription("Comma-separated list of labels to apply (optional)")), + mcp.WithArgument("assignees", mcp.ArgumentDescription("Comma-separated list of assignees (optional)")), + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + owner := request.Params.Arguments["owner"] + repo := request.Params.Arguments["repo"] + title := request.Params.Arguments["title"] + description := request.Params.Arguments["description"] + + labels := "" + if l, exists := request.Params.Arguments["labels"]; exists { + labels = fmt.Sprintf("%v", l) + } + + assignees := "" + if a, exists := request.Params.Arguments["assignees"]; exists { + assignees = fmt.Sprintf("%v", a) + } + + messages := []mcp.PromptMessage{ + { + Role: "system", + Content: mcp.NewTextContent("You are a development workflow assistant helping to create GitHub issues and generate corresponding pull requests to fix them. You should: 1) Create a well-structured issue with clear problem description, 2) Assign it to Copilot coding agent to generate a solution, and 3) Monitor the PR creation process."), + }, + { + Role: "user", + Content: mcp.NewTextContent(fmt.Sprintf("I need to create an issue titled '%s' in %s/%s and then have a PR generated to fix it. The issue description is: %s%s%s", + title, owner, repo, description, + func() string { + if labels != "" { + return fmt.Sprintf("\n\nLabels to apply: %s", labels) + } + return "" + }(), + func() string { + if assignees != "" { + return fmt.Sprintf("\nAssignees: %s", assignees) + } + return "" + }())), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("I'll help you create the issue '%s' in %s/%s and then coordinate with Copilot to generate a fix. Let me start by creating the issue with the provided details.", title, owner, repo)), + }, + { + Role: "user", + Content: mcp.NewTextContent("Perfect! Please:\n1. Create the issue with the title, description, labels, and assignees\n2. Once created, assign it to Copilot coding agent to generate a solution\n3. Monitor the process and let me know when the PR is ready for review"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("Excellent plan! Here's what I'll do:\n\n1. ✅ Create the issue with all specified details\n2. 🤖 Assign to Copilot coding agent for automated fix\n3. 📋 Monitor progress and notify when PR is created\n4. 🔍 Provide PR details for your review\n\nLet me start by creating the issue."), + }, + } + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + } +} From 4b64121b97f456163ef78a3d244462647068f585 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 30 Jul 2025 13:37:24 +0200 Subject: [PATCH 10/13] Updated descriptions for search tools (#737) * Updated description for search_code * Update toolsnaps and docs --- README.md | 12 +++++----- pkg/github/__toolsnaps__/search_code.snap | 6 ++--- .../__toolsnaps__/search_repositories.snap | 4 ++-- pkg/github/__toolsnaps__/search_users.snap | 6 ++--- pkg/github/search.go | 22 ++++++++++--------- 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 00bb81910..165f33622 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,7 @@ The following sets of tools are available (all are on by default): - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `query`: Search query using GitHub organizations search syntax scoped to type:org (string, required) + - `query`: Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org. (string, required) - `sort`: Sort field by category (string, optional) @@ -836,16 +836,16 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - **search_code** - Search code - - `order`: Sort order (string, optional) + - `order`: Sort order for results (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `query`: Search query using GitHub code search syntax (string, required) + - `query`: Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more. (string, required) - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `query`: Search query (string, required) + - `query`: Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering. (string, required) @@ -875,8 +875,8 @@ The following sets of tools are available (all are on by default): - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `query`: Search query using GitHub users search syntax scoped to type:user (string, required) - - `sort`: Sort field by category (string, optional) + - `query`: User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user. (string, required) + - `sort`: Sort users by number of followers or repositories, or when the person joined GitHub. (string, optional) diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index e341f3e38..4ef40c5f8 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -3,11 +3,11 @@ "title": "Search code", "readOnlyHint": true }, - "description": "Search for code across GitHub repositories", + "description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.", "inputSchema": { "properties": { "order": { - "description": "Sort order", + "description": "Sort order for results", "enum": [ "asc", "desc" @@ -26,7 +26,7 @@ "type": "number" }, "query": { - "description": "Search query using GitHub code search syntax", + "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", "type": "string" }, "sort": { diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index b6b6d1d44..d283a2cc0 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -3,7 +3,7 @@ "title": "Search repositories", "readOnlyHint": true }, - "description": "Search for GitHub repositories", + "description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.", "inputSchema": { "properties": { "page": { @@ -18,7 +18,7 @@ "type": "number" }, "query": { - "description": "Search query", + "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index 5cf9796f2..73ff7a43c 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -3,7 +3,7 @@ "title": "Search users", "readOnlyHint": true }, - "description": "Search for GitHub users exclusively", + "description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.", "inputSchema": { "properties": { "order": { @@ -26,11 +26,11 @@ "type": "number" }, "query": { - "description": "Search query using GitHub users search syntax scoped to type:user", + "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user.", "type": "string" }, "sort": { - "description": "Sort field by category", + "description": "Sort users by number of followers or repositories, or when the person joined GitHub.", "enum": [ "followers", "repositories", diff --git a/pkg/github/search.go b/pkg/github/search.go index 476ac0151..cbde0f7c6 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -16,14 +16,15 @@ import ( // SearchRepositories creates a tool to search for GitHub repositories. func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_repositories", - mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Search for GitHub repositories")), + mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("query", mcp.Required(), - mcp.Description("Search query"), + mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), ), WithPagination(), ), @@ -78,20 +79,20 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF // SearchCode creates a tool to search for code across GitHub repositories. func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_code", - mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Search for code across GitHub repositories")), + mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("query", mcp.Required(), - mcp.Description("Search query using GitHub code search syntax"), + mcp.Description("Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more."), ), mcp.WithString("sort", mcp.Description("Sort field ('indexed' only)"), ), mcp.WithString("order", - mcp.Description("Sort order"), + mcp.Description("Sort order for results"), mcp.Enum("asc", "desc"), ), WithPagination(), @@ -258,17 +259,17 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand // SearchUsers creates a tool to search for GitHub users. func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_users", - mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users exclusively")), + mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("query", mcp.Required(), - mcp.Description("Search query using GitHub users search syntax scoped to type:user"), + mcp.Description("User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user."), ), mcp.WithString("sort", - mcp.Description("Sort field by category"), + mcp.Description("Sort users by number of followers or repositories, or when the person joined GitHub."), mcp.Enum("followers", "repositories", "joined"), ), mcp.WithString("order", @@ -282,14 +283,15 @@ func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (t // SearchOrgs creates a tool to search for GitHub organizations. func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_orgs", - mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Search for GitHub organizations exclusively")), + mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("query", mcp.Required(), - mcp.Description("Search query using GitHub organizations search syntax scoped to type:org"), + mcp.Description("Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org."), ), mcp.WithString("sort", mcp.Description("Sort field by category"), From 1c6171b24f1ba3f22ad62bf7998e717a6d861b63 Mon Sep 17 00:00:00 2001 From: Maximillian Polhill Date: Wed, 30 Jul 2025 10:12:12 -0400 Subject: [PATCH 11/13] Feat: Add initial Gist tools (#340) * Add initial Gist tools: ListGists, CreateGist * Add UpdateGist tool * Add documentation for initial Gist tools --------- Co-authored-by: Matt Holloway --- README.md | 27 ++- docs/remote-server.md | 1 + pkg/github/gists.go | 259 ++++++++++++++++++++ pkg/github/gists_test.go | 507 +++++++++++++++++++++++++++++++++++++++ pkg/github/tools.go | 10 + 5 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 pkg/github/gists.go create mode 100644 pkg/github/gists_test.go diff --git a/README.md b/README.md index 165f33622..3c9eb670f 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ The following sets of tools are available (all are on by default): | `dependabot` | Dependabot tools | | `discussions` | GitHub Discussions related tools | | `experiments` | Experimental features that are not considered stable yet | +| `gists` | GitHub Gist related tools | | `issues` | GitHub Issues related tools | | `notifications` | GitHub Notifications related tools | | `orgs` | GitHub Organization related tools | @@ -472,6 +473,30 @@ The following sets of tools are available (all are on by default):
+Gists + +- **create_gist** - Create Gist + - `content`: Content for simple single-file gist creation (string, required) + - `description`: Description of the gist (string, optional) + - `filename`: Filename for simple single-file gist creation (string, required) + - `public`: Whether the gist is public (boolean, optional) + +- **list_gists** - List Gists + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `since`: Only gists updated after this time (ISO 8601 timestamp) (string, optional) + - `username`: GitHub username (omit for authenticated user's gists) (string, optional) + +- **update_gist** - Update Gist + - `content`: Content for the file (string, required) + - `description`: Updated description of the gist (string, optional) + - `filename`: Filename to update or create (string, required) + - `gist_id`: ID of the gist to update (string, required) + +
+ +
+ Issues - **add_issue_comment** - Add comment to issue @@ -1049,4 +1074,4 @@ The exported Go API of this module should currently be considered unstable, and ## License -This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. +This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. \ No newline at end of file diff --git a/docs/remote-server.md b/docs/remote-server.md index 49794c605..5f57f4961 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -25,6 +25,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | +| Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | | Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | | Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | diff --git a/pkg/github/gists.go b/pkg/github/gists.go new file mode 100644 index 000000000..403804cad --- /dev/null +++ b/pkg/github/gists.go @@ -0,0 +1,259 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ListGists creates a tool to list gists for a user +func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_gists", + mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_GISTS", "List Gists"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("username", + mcp.Description("GitHub username (omit for authenticated user's gists)"), + ), + mcp.WithString("since", + mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + username, err := OptionalParam[string](request, "username") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.GistListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + // Parse since timestamp if provided + if since != "" { + sinceTime, err := parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil + } + opts.Since = sinceTime + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + gists, resp, err := client.Gists.List(ctx, username, opts) + if err != nil { + return nil, fmt.Errorf("failed to list gists: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list gists: %s", string(body))), nil + } + + r, err := json.Marshal(gists) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CreateGist creates a tool to create a new gist +func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_gist", + mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_GIST", "Create Gist"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("description", + mcp.Description("Description of the gist"), + ), + mcp.WithString("filename", + mcp.Required(), + mcp.Description("Filename for simple single-file gist creation"), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Content for simple single-file gist creation"), + ), + mcp.WithBoolean("public", + mcp.Description("Whether the gist is public"), + mcp.DefaultBool(false), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filename, err := RequiredParam[string](request, "filename") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + public, err := OptionalParam[bool](request, "public") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } + + gist := &github.Gist{ + Files: files, + Public: github.Ptr(public), + Description: github.Ptr(description), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + createdGist, resp, err := client.Gists.Create(ctx, gist) + if err != nil { + return nil, fmt.Errorf("failed to create gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create gist: %s", string(body))), nil + } + + r, err := json.Marshal(createdGist) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// UpdateGist creates a tool to edit an existing gist +func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_gist", + mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_GIST", "Update Gist"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("gist_id", + mcp.Required(), + mcp.Description("ID of the gist to update"), + ), + mcp.WithString("description", + mcp.Description("Updated description of the gist"), + ), + mcp.WithString("filename", + mcp.Required(), + mcp.Description("Filename to update or create"), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Content for the file"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + gistID, err := RequiredParam[string](request, "gist_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filename, err := RequiredParam[string](request, "filename") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } + + gist := &github.Gist{ + Files: files, + Description: github.Ptr(description), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) + if err != nil { + return nil, fmt.Errorf("failed to update gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to update gist: %s", string(body))), nil + } + + r, err := json.Marshal(updatedGist) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go new file mode 100644 index 000000000..423422925 --- /dev/null +++ b/pkg/github/gists_test.go @@ -0,0 +1,507 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListGists(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_gists", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "username") + assert.Contains(t, tool.InputSchema.Properties, "since") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Empty(t, tool.InputSchema.Required) + + // Setup mock gists for success case + mockGists := []*github.Gist{ + { + ID: github.Ptr("gist1"), + Description: github.Ptr("First Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), + Public: github.Ptr(true), + CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + "file1.txt": { + Filename: github.Ptr("file1.txt"), + Content: github.Ptr("content of file 1"), + }, + }, + }, + { + ID: github.Ptr("gist2"), + Description: github.Ptr("Second Gist"), + HTMLURL: github.Ptr("https://gist.github.com/testuser/gist2"), + Public: github.Ptr(false), + CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)}, + Owner: &github.User{Login: github.Ptr("testuser")}, + Files: map[github.GistFilename]github.GistFile{ + "file2.js": { + Filename: github.Ptr("file2.js"), + Content: github.Ptr("console.log('hello');"), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedGists []*github.Gist + expectedErrMsg string + }{ + { + name: "list authenticated user's gists", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetGists, + mockGists, + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedGists: mockGists, + }, + { + name: "list specific user's gists", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersGistsByUsername, + mockResponse(t, http.StatusOK, mockGists), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + }, + expectError: false, + expectedGists: mockGists, + }, + { + name: "list gists with pagination and since parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetGists, + expectQueryParams(t, map[string]string{ + "since": "2023-01-01T00:00:00Z", + "page": "2", + "per_page": "5", + }).andThen( + mockResponse(t, http.StatusOK, mockGists), + ), + ), + ), + requestArgs: map[string]interface{}{ + "since": "2023-01-01T00:00:00Z", + "page": float64(2), + "perPage": float64(5), + }, + expectError: false, + expectedGists: mockGists, + }, + { + name: "invalid since parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetGists, + mockGists, + ), + ), + requestArgs: map[string]interface{}{ + "since": "invalid-date", + }, + expectError: true, + expectedErrMsg: "invalid since timestamp", + }, + { + name: "list gists fails with error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetGists, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "failed to list gists", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListGists(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedGists []*github.Gist + err = json.Unmarshal([]byte(textContent.Text), &returnedGists) + require.NoError(t, err) + + assert.Len(t, returnedGists, len(tc.expectedGists)) + for i, gist := range returnedGists { + assert.Equal(t, *tc.expectedGists[i].ID, *gist.ID) + assert.Equal(t, *tc.expectedGists[i].Description, *gist.Description) + assert.Equal(t, *tc.expectedGists[i].HTMLURL, *gist.HTMLURL) + assert.Equal(t, *tc.expectedGists[i].Public, *gist.Public) + } + }) + } +} + +func Test_CreateGist(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "create_gist", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "filename") + assert.Contains(t, tool.InputSchema.Properties, "content") + assert.Contains(t, tool.InputSchema.Properties, "public") + + // Verify required parameters + assert.Contains(t, tool.InputSchema.Required, "filename") + assert.Contains(t, tool.InputSchema.Required, "content") + + // Setup mock data for test cases + createdGist := &github.Gist{ + ID: github.Ptr("new-gist-id"), + Description: github.Ptr("Test Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/new-gist-id"), + Public: github.Ptr(false), + CreatedAt: &github.Timestamp{Time: time.Now()}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + "test.go": { + Filename: github.Ptr("test.go"), + Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedGist *github.Gist + }{ + { + name: "create gist successfully", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostGists, + mockResponse(t, http.StatusCreated, createdGist), + ), + ), + requestArgs: map[string]interface{}{ + "filename": "test.go", + "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}", + "description": "Test Gist", + "public": false, + }, + expectError: false, + expectedGist: createdGist, + }, + { + name: "missing required filename", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "content": "test content", + "description": "Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: filename", + }, + { + name: "missing required content", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "filename": "test.go", + "description": "Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: content", + }, + { + name: "api returns error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostGists, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "filename": "test.go", + "content": "package main", + "description": "Test Gist", + }, + expectError: true, + expectedErrMsg: "failed to create gist", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateGist(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var gist *github.Gist + err = json.Unmarshal([]byte(textContent.Text), &gist) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedGist.ID, *gist.ID) + assert.Equal(t, *tc.expectedGist.Description, *gist.Description) + assert.Equal(t, *tc.expectedGist.HTMLURL, *gist.HTMLURL) + assert.Equal(t, *tc.expectedGist.Public, *gist.Public) + + // Verify file content + for filename, expectedFile := range tc.expectedGist.Files { + actualFile, exists := gist.Files[filename] + assert.True(t, exists) + assert.Equal(t, *expectedFile.Filename, *actualFile.Filename) + assert.Equal(t, *expectedFile.Content, *actualFile.Content) + } + }) + } +} + +func Test_UpdateGist(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "update_gist", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "gist_id") + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "filename") + assert.Contains(t, tool.InputSchema.Properties, "content") + + // Verify required parameters + assert.Contains(t, tool.InputSchema.Required, "gist_id") + assert.Contains(t, tool.InputSchema.Required, "filename") + assert.Contains(t, tool.InputSchema.Required, "content") + + // Setup mock data for test cases + updatedGist := &github.Gist{ + ID: github.Ptr("existing-gist-id"), + Description: github.Ptr("Updated Test Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"), + Public: github.Ptr(true), + UpdatedAt: &github.Timestamp{Time: time.Now()}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + "updated.go": { + Filename: github.Ptr("updated.go"), + Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedGist *github.Gist + }{ + { + name: "update gist successfully", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchGistsByGistId, + mockResponse(t, http.StatusOK, updatedGist), + ), + ), + requestArgs: map[string]interface{}{ + "gist_id": "existing-gist-id", + "filename": "updated.go", + "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}", + "description": "Updated Test Gist", + }, + expectError: false, + expectedGist: updatedGist, + }, + { + name: "missing required gist_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "filename": "updated.go", + "content": "updated content", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: gist_id", + }, + { + name: "missing required filename", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "gist_id": "existing-gist-id", + "content": "updated content", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: filename", + }, + { + name: "missing required content", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "gist_id": "existing-gist-id", + "filename": "updated.go", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: content", + }, + { + name: "api returns error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchGistsByGistId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "gist_id": "nonexistent-gist-id", + "filename": "updated.go", + "content": "package main", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "failed to update gist", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := UpdateGist(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var gist *github.Gist + err = json.Unmarshal([]byte(textContent.Text), &gist) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedGist.ID, *gist.ID) + assert.Equal(t, *tc.expectedGist.Description, *gist.Description) + assert.Equal(t, *tc.expectedGist.HTMLURL, *gist.HTMLURL) + + // Verify file content + for filename, expectedFile := range tc.expectedGist.Files { + actualFile, exists := gist.Files[filename] + assert.True(t, exists) + assert.Equal(t, *expectedFile.Filename, *actualFile.Filename) + assert.Equal(t, *expectedFile.Content, *actualFile.Content) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 499defc86..7fb1d39c0 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -164,6 +164,15 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetMe(getClient, t)), ) + gists := toolsets.NewToolset("gists", "GitHub Gist related tools"). + AddReadTools( + toolsets.NewServerTool(ListGists(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateGist(getClient, t)), + toolsets.NewServerTool(UpdateGist(getClient, t)), + ) + // Add toolsets to the group tsg.AddToolset(contextTools) tsg.AddToolset(repos) @@ -178,6 +187,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(notifications) tsg.AddToolset(experiments) tsg.AddToolset(discussions) + tsg.AddToolset(gists) return tsg } From 45e90aedd4445e0e61ead8cb165b45cc8e249f1e Mon Sep 17 00:00:00 2001 From: MayorFaj <127399119+MayorFaj@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:05:38 +0100 Subject: [PATCH 12/13] feat: add reviewers parameter to UpdatePullRequest (#285) * feat: add reviewers parameter to UpdatePullRequest and update tests * Update pullrequests.go * feat: enhance update pull request functionality with reviewers support * update README to clarify optional reviewers parameter in API documentation- go run ./cmd/github-mcp-server generate-docs * feat: enhance UpdatePullRequest to return early if no updates or reviewers are provided * Add updating draft state to `update_pull_request` tool (#774) * initial impl of pull request draft state update * appease linter * update README * add nosec * fixed err return type for json marshalling * add gql test * Add support for org-level discussions in list_discussions tool (#775) * make repo optional, and default to .github when not provided. improve tool description * autogen * update tests * small copy paste error fixes * refactor: streamline UpdatePullRequest logic and enhance test cases for reviewer updates * refactor: remove redundant draft update tests and streamline UpdatePullRequest logic * test: add unit tests for updating pull request draft state * refactor: simplify UpdatePullRequest tests by removing unused mock data --------- Co-authored-by: Matt Holloway Co-authored-by: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> --- README.md | 1 + .../__toolsnaps__/update_pull_request.snap | 7 ++ pkg/github/discussions_test.go | 3 +- pkg/github/pullrequests.go | 56 ++++++++++++- pkg/github/pullrequests_test.go | 78 ++++++++++++++++++- 5 files changed, 138 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3c9eb670f..b40974e20 100644 --- a/README.md +++ b/README.md @@ -766,6 +766,7 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number to update (number, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames to request reviews from (string[], optional) - `state`: New state (string, optional) - `title`: New title (string, optional) diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index c44d8afa0..25170ed5f 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -34,6 +34,13 @@ "description": "Repository name", "type": "string" }, + "reviewers": { + "description": "GitHub usernames to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, "state": { "description": "New state", "enum": [ diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 1fa90b403..945783ae1 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -87,7 +87,6 @@ var ( "url": "https://github.com/owner/.github/discussions/4", "category": map[string]any{"name": "General"}, }, - } // Ordered mock responses @@ -190,7 +189,7 @@ var ( "startCursor": "", "endCursor": "", }, - "totalCount": 4, + "totalCount": 4, }, }, }) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index f380843b0..f82117cad 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -241,6 +241,12 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra mcp.WithBoolean("maintainer_can_modify", mcp.Description("Allow maintainer edits"), ), + mcp.WithArray("reviewers", + mcp.Description("GitHub usernames to request reviews from"), + mcp.Items(map[string]interface{}{ + "type": "string", + }), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -256,15 +262,17 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra return mcp.NewToolResultError(err.Error()), nil } + // Check if draft parameter is provided draftProvided := request.GetArguments()["draft"] != nil var draftValue bool if draftProvided { draftValue, err = OptionalParam[bool](request, "draft") if err != nil { - return nil, err + return mcp.NewToolResultError(err.Error()), nil } } + // Build the update struct only with provided fields update := &github.PullRequest{} restUpdateNeeded := false @@ -303,10 +311,18 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra restUpdateNeeded = true } - if !restUpdateNeeded && !draftProvided { + // Handle reviewers separately + reviewers, err := OptionalStringArrayParam(request, "reviewers") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // If no updates, no draft change, and no reviewers, return error early + if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 { return mcp.NewToolResultError("No update parameters provided."), nil } + // Handle REST API updates (title, body, state, base, maintainer_can_modify) if restUpdateNeeded { client, err := getClient(ctx) if err != nil { @@ -332,6 +348,7 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra } } + // Handle draft status changes using GraphQL if draftProvided { gqlClient, err := getGQLClient(ctx) if err != nil { @@ -397,6 +414,41 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra } } + // Handle reviewer requests + if len(reviewers) > 0 { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + reviewersRequest := github.ReviewersRequest{ + Reviewers: reviewers, + } + + _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request reviewers", + resp, + err, + ), nil + } + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to request reviewers: %s", string(body))), nil + } + } + + // Get the final state of the PR to return client, err := getClient(ctx) if err != nil { return nil, err diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 179458115..3a99d9f46 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -151,6 +151,7 @@ func Test_UpdatePullRequest(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "state") assert.Contains(t, tool.InputSchema.Properties, "base") assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") + assert.Contains(t, tool.InputSchema.Properties, "reviewers") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) // Setup mock PR for success case @@ -173,6 +174,17 @@ func Test_UpdatePullRequest(t *testing.T) { State: github.Ptr("closed"), // State updated } + // Mock PR for when there are no updates but we still need a response + mockPRWithReviewers := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + RequestedReviewers: []*github.User{ + {Login: github.Ptr("reviewer1")}, + {Login: github.Ptr("reviewer2")}, + }, + } + tests := []struct { name string mockedClient *http.Client @@ -238,6 +250,28 @@ func Test_UpdatePullRequest(t *testing.T) { expectError: false, expectedPR: mockClosedPR, }, + { + name: "successful PR update with reviewers", + mockedClient: mock.NewMockedHTTPClient( + // Mock for RequestReviewers call, returning the PR with reviewers + mock.WithRequestMatch( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + mockPRWithReviewers, + ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockPRWithReviewers, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []interface{}{"reviewer1", "reviewer2"}, + }, + expectError: false, + expectedPR: mockPRWithReviewers, + }, { name: "successful PR update (title only)", mockedClient: mock.NewMockedHTTPClient( @@ -295,6 +329,27 @@ func Test_UpdatePullRequest(t *testing.T) { expectError: true, expectedErrMsg: "failed to update pull request", }, + { + name: "request reviewers fails", + mockedClient: mock.NewMockedHTTPClient( + // Then reviewer request fails + mock.WithRequestMatchHandler( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []interface{}{"invalid-user"}, + }, + expectError: true, + expectedErrMsg: "failed to request reviewers", + }, } for _, tc := range tests { @@ -347,6 +402,26 @@ func Test_UpdatePullRequest(t *testing.T) { if tc.expectedPR.MaintainerCanModify != nil { assert.Equal(t, *tc.expectedPR.MaintainerCanModify, *returnedPR.MaintainerCanModify) } + + // Check reviewers if they exist in the expected PR + if len(tc.expectedPR.RequestedReviewers) > 0 { + assert.NotNil(t, returnedPR.RequestedReviewers) + assert.Equal(t, len(tc.expectedPR.RequestedReviewers), len(returnedPR.RequestedReviewers)) + + // Create maps of reviewer logins for easy comparison + expectedReviewers := make(map[string]bool) + for _, reviewer := range tc.expectedPR.RequestedReviewers { + expectedReviewers[*reviewer.Login] = true + } + + actualReviewers := make(map[string]bool) + for _, reviewer := range returnedPR.RequestedReviewers { + actualReviewers[*reviewer.Login] = true + } + + // Compare the maps + assert.Equal(t, expectedReviewers, actualReviewers) + } }) } } @@ -529,9 +604,6 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedPR) require.NoError(t, err) assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - if tc.expectedPR.Draft != nil { - assert.Equal(t, *tc.expectedPR.Draft, *returnedPR.Draft) - } }) } } From ff6e859555715fc756c25abd2542bccf8cef08fd Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 1 Aug 2025 17:55:11 +0200 Subject: [PATCH 13/13] add title to get_discussion query (#803) --- pkg/github/discussions.go | 2 ++ pkg/github/discussions_test.go | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 905a1b709..91487f7aa 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -295,6 +295,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper Repository struct { Discussion struct { Number githubv4.Int + Title githubv4.String Body githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` @@ -315,6 +316,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper d := q.Repository.Discussion discussion := &github.Discussion{ Number: github.Ptr(int(d.Number)), + Title: github.Ptr(string(d.Title)), Body: github.Ptr(string(d.Body)), HTMLURL: github.Ptr(string(d.URL)), CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 945783ae1..9458dfce0 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -484,7 +484,7 @@ func Test_GetDiscussion(t *testing.T) { assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output - qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,body,createdAt,url,category{name}}}}" + qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" vars := map[string]interface{}{ "owner": "owner", @@ -503,6 +503,7 @@ func Test_GetDiscussion(t *testing.T) { response: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{"discussion": map[string]any{ "number": 1, + "title": "Test Discussion Title", "body": "This is a test discussion", "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2025-04-25T12:00:00Z", @@ -513,6 +514,7 @@ func Test_GetDiscussion(t *testing.T) { expected: &github.Discussion{ HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), Number: github.Ptr(1), + Title: github.Ptr("Test Discussion Title"), Body: github.Ptr("This is a test discussion"), CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, DiscussionCategory: &github.DiscussionCategory{ @@ -549,6 +551,7 @@ func Test_GetDiscussion(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(text), &out)) assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) assert.Equal(t, *tc.expected.Number, *out.Number) + assert.Equal(t, *tc.expected.Title, *out.Title) assert.Equal(t, *tc.expected.Body, *out.Body) // Check category label assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy