From f041695141fde74c7b7d5613dac87461ec8a0603 Mon Sep 17 00:00:00 2001 From: kerobbi Date: Thu, 16 Jul 2026 15:49:27 +0100 Subject: [PATCH 1/2] make search_issues field value enrichment best-effort --- pkg/github/issues.go | 19 +++++++------ pkg/github/issues_test.go | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index b53ee6d596..db6fe856e2 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -2177,9 +2177,9 @@ func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, n return enrichment, nil } -// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values -// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options -// (e.g. IFC labelling). +// searchIssuesHandler runs the REST issues search, enriches each hit (best-effort) with custom +// field values fetched via a single follow-up GraphQL nodes() query, and applies any post-process +// options (e.g. IFC labelling). func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, mode searchMode, options ...searchOption) (*mcp.CallToolResult, error) { const errorPrefix = "failed to search issues" @@ -2206,15 +2206,18 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil } + // The field value enrichment is best-effort: a failure here (e.g. a server whose + // GraphQL schema predates the issueFieldValues field) must never fail the search. var fieldValuesByID map[string][]MinimalFieldValue if len(result.Issues) > 0 { gqlClient, err := deps.GetGQLClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil - } - fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil + _, _ = ghErrors.NewGitHubGraphQLErrorToCtx(ctx, errorPrefix+": failed to get GitHub GraphQL client", err) + } else { + fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) + if err != nil { + _, _ = ghErrors.NewGitHubGraphQLErrorToCtx(ctx, errorPrefix+": failed to fetch issue field values", err) + } } } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index fb747a1283..1f8386b092 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1687,6 +1687,63 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { assert.Empty(t, response.Items[1].FieldValues) } +func Test_SearchIssues_FieldValuesEnrichmentUnsupported(t *testing.T) { + // Verify search_issues still returns its REST hits when the server's GraphQL + // schema does not support the issueFieldValues enrichment. + serverTool := SearchIssues(translations.NullTranslationHelper) + + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Bug: Something is broken"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + NodeID: github.Ptr("I_node_42"), + User: &github.User{Login: github.Ptr("user1")}, + }, + }, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }) + + gqlVars := map[string]any{ + "ids": []any{"I_node_42"}, + } + gqlResponse := githubv4mock.ErrorResponse("Field 'issueFieldValues' doesn't exist on type 'Issue'") + + const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" + matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "repo:owner/repo is:open", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "expected result to not be an error") + + textContent := getTextResult(t, result) + + var response SearchIssuesResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + require.Equal(t, 1, *response.Total) + require.Len(t, response.Items, 1) + assert.Equal(t, 42, *response.Items[0].Number) + assert.Empty(t, response.Items[0].FieldValues) +} + func Test_CreateIssue(t *testing.T) { // Verify tool definition once serverTool := IssueWrite(translations.NullTranslationHelper) From b9a7ebfb23a016a11fda95d887869647ef3fe072 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 19 Aug 2026 12:31:19 +0200 Subject: [PATCH 2/2] fix(issues): narrow search field enrichment fallback Only tolerate GraphQL schema validation failures that show the optional Issue.issueFieldValues selection or its known fragments are unsupported. Surface client, auth, rate-limit, network, resolver, malformed response, and unrelated GraphQL failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/issues.go | 64 ++++++-- pkg/github/issues_test.go | 311 ++++++++++++++++++++++++-------------- 2 files changed, 251 insertions(+), 124 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index db6fe856e2..b3c8dddf21 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -730,7 +730,48 @@ func getIssueQueryTypeWithoutFieldValues(hasLabels bool, hasSince bool) issueQue } } +func isUnsupportedIssueFieldValuesSchemaError(err error) bool { + if err == nil { + return false + } + + message := strings.ToLower(err.Error()) + mentionsIssueType := strings.Contains(message, "on type 'issue'") || + strings.Contains(message, `on type "issue"`) || + strings.Contains(message, "on type issue") + if strings.Contains(message, "issuefieldvalues") && + mentionsIssueType && + (strings.Contains(message, "doesn't exist on type") || + strings.Contains(message, "does not exist on type") || + strings.Contains(message, "cannot query field") || + strings.Contains(message, "is not defined on type")) { + return true + } + + issueFieldTypes := [...]string{ + "issuefielddate", + "issuefieldnumber", + "issuefieldsingleselect", + "issuefieldtext", + } + for _, issueFieldType := range issueFieldTypes { + if !strings.Contains(message, issueFieldType) { + continue + } + return strings.Contains(message, "unknown type") || + strings.Contains(message, "isn't a defined type") || + strings.Contains(message, "is not a defined type") || + strings.Contains(message, "fragment cannot be spread") || + strings.Contains(message, "can never be of type") + } + return false +} + func isUnsupportedListIssuesIssueFieldsError(err error) bool { + if isUnsupportedIssueFieldValuesSchemaError(err) { + return true + } + message := err.Error() if strings.Contains(message, "IssueFieldValueFilter") { return true @@ -2177,9 +2218,9 @@ func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, n return enrichment, nil } -// searchIssuesHandler runs the REST issues search, enriches each hit (best-effort) with custom -// field values fetched via a single follow-up GraphQL nodes() query, and applies any post-process -// options (e.g. IFC labelling). +// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values +// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options +// (e.g. IFC labelling). func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, mode searchMode, options ...searchOption) (*mcp.CallToolResult, error) { const errorPrefix = "failed to search issues" @@ -2206,18 +2247,21 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil } - // The field value enrichment is best-effort: a failure here (e.g. a server whose - // GraphQL schema predates the issueFieldValues field) must never fail the search. var fieldValuesByID map[string][]MinimalFieldValue if len(result.Issues) > 0 { gqlClient, err := deps.GetGQLClient(ctx) if err != nil { - _, _ = ghErrors.NewGitHubGraphQLErrorToCtx(ctx, errorPrefix+": failed to get GitHub GraphQL client", err) - } else { - fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) - if err != nil { - _, _ = ghErrors.NewGitHubGraphQLErrorToCtx(ctx, errorPrefix+": failed to fetch issue field values", err) + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil + } + fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) + if err != nil { + const enrichmentError = errorPrefix + ": failed to fetch issue field values" + if !isUnsupportedIssueFieldValuesSchemaError(err) { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, enrichmentError, err), nil } + // Older GHES schemas can lack this optional enrichment. Preserve the REST + // search results while retaining the compatibility failure for observability. + _, _ = ghErrors.NewGitHubGraphQLErrorToCtx(ctx, enrichmentError, err) } } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 1f8386b092..1def6b67e0 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -15,6 +15,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/http/headers" transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" @@ -52,6 +53,8 @@ func newRepoAccessHTTPClient() *http.Client { const issueReadEnrichmentQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}},parent{number,title,state,url,author{login},repository{nameWithOwner}},closedByPullRequestsReferences(first: 5, includeClosedPrs: true, orderByState: true){totalCount,nodes{number,title,state,url,author{login},repository{nameWithOwner}}},subIssuesSummary{total,completed,percentCompleted}}}}" +const searchIssueFieldValuesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" + // newIssueReadEnrichmentMatcher builds a matcher for the issue_read `get` enrichment query for a // single issue node ID. func newIssueReadEnrichmentMatcher(nodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { @@ -1596,37 +1599,10 @@ func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any { func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { serverTool := SearchIssues(translations.NullTranslationHelper) - mockSearchResult := &github.IssuesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), - Issues: []*github.Issue{ - { - Number: github.Ptr(42), - Title: github.Ptr("Bug: Something is broken"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - NodeID: github.Ptr("I_node_42"), - User: &github.User{Login: github.Ptr("user1")}, - }, - { - Number: github.Ptr(43), - Title: github.Ptr("Feature request"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), - NodeID: github.Ptr("I_node_43"), - User: &github.User{Login: github.Ptr("user2")}, - }, - }, - } - - restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), - }) - gqlVars := map[string]any{ - "ids": []any{"I_node_42", "I_node_43"}, + "ids": []any{"I_node_42"}, } - gqlResponse := githubv4mock.DataResponse(map[string]any{ + supportedResponse := githubv4mock.DataResponse(map[string]any{ "nodes": []map[string]any{ { "id": "I_node_42", @@ -1645,103 +1621,210 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { }, }, }, - { - "id": "I_node_43", - "issueFieldValues": map[string]any{ - "nodes": []map[string]any{}, - }, - }, }, }) - const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" - matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) - - deps := BaseDeps{ - Client: mustNewGHClient(t, restClient), - GQLClient: gqlClient, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(map[string]any{ - "query": "repo:owner/repo is:open", - }) - - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, "expected result to not be an error") - - textContent := getTextResult(t, result) - - var response SearchIssuesResponse - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) - require.Equal(t, 2, *response.Total) - require.Len(t, response.Items, 2) - assert.Equal(t, 42, *response.Items[0].Number) - assert.Equal(t, []MinimalFieldValue{ - {Field: "priority", Value: "P1"}, - {Field: "estimate", Value: "2.5"}, - }, response.Items[0].FieldValues) - assert.Equal(t, 43, *response.Items[1].Number) - assert.Empty(t, response.Items[1].FieldValues) -} - -func Test_SearchIssues_FieldValuesEnrichmentUnsupported(t *testing.T) { - // Verify search_issues still returns its REST hits when the server's GraphQL - // schema does not support the issueFieldValues enrichment. - serverTool := SearchIssues(translations.NullTranslationHelper) - - mockSearchResult := &github.IssuesSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), - Issues: []*github.Issue{ - { - Number: github.Ptr(42), - Title: github.Ptr("Bug: Something is broken"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - NodeID: github.Ptr("I_node_42"), - User: &github.User{Login: github.Ptr("user1")}, - }, + tests := []struct { + name string + gqlResponse githubv4mock.GQLResponse + gqlHTTPClient *http.Client + gqlClientError string + wantErrorText string + wantFieldValues bool + wantObservedGQLError bool + useFieldsFilter bool + }{ + { + name: "supported enrichment", + gqlResponse: supportedResponse, + wantFieldValues: true, + useFieldsFilter: true, + }, + { + name: "GHES missing selected field with single quotes", + gqlResponse: githubv4mock.ErrorResponse("Field 'issueFieldValues' doesn't exist on type 'Issue'"), + wantObservedGQLError: true, + }, + { + name: "GHES missing selected field with double quotes", + gqlResponse: githubv4mock.ErrorResponse(`Cannot query field "issueFieldValues" on type "Issue".`), + wantObservedGQLError: true, + useFieldsFilter: true, + }, + { + name: "GHES missing issue field value type", + gqlResponse: githubv4mock.ErrorResponse(`Unknown type "IssueFieldDateValue".`), + wantObservedGQLError: true, + }, + { + name: "GHES unsupported issue field value fragment", + gqlResponse: githubv4mock.ErrorResponse(`Fragment cannot be spread here as objects of type "IssueFieldValue" can never be of type "IssueFieldTextValue".`), + wantObservedGQLError: true, + }, + { + name: "unrelated GraphQL validation error", + gqlResponse: githubv4mock.ErrorResponse("Field 'viewer' doesn't exist on type 'Query'"), + wantErrorText: "Field 'viewer' doesn't exist on type 'Query'", + wantObservedGQLError: true, + }, + { + name: "same field missing on unrelated type", + gqlResponse: githubv4mock.ErrorResponse("Field 'issueFieldValues' doesn't exist on type 'PullRequest'"), + wantErrorText: "Field 'issueFieldValues' doesn't exist on type 'PullRequest'", + wantObservedGQLError: true, + }, + { + name: "list-only filter input type error", + gqlResponse: githubv4mock.ErrorResponse("IssueFieldValueFilter isn't a defined input type (on $issueFieldValues)"), + wantErrorText: "IssueFieldValueFilter isn't a defined input type", + wantObservedGQLError: true, + }, + { + name: "issue field values resolver error", + gqlResponse: githubv4mock.ErrorResponse("Something went wrong while resolving 'issueFieldValues'"), + wantErrorText: "Something went wrong while resolving 'issueFieldValues'", + wantObservedGQLError: true, + }, + { + name: "rate limit error", + gqlResponse: githubv4mock.ErrorResponse("API rate limit exceeded"), + wantErrorText: "API rate limit exceeded", + wantObservedGQLError: true, + }, + { + name: "authentication error", + gqlResponse: githubv4mock.ErrorResponse("Bad credentials"), + wantErrorText: "Bad credentials", + wantObservedGQLError: true, + }, + { + name: "malformed GraphQL response", + gqlResponse: githubv4mock.DataResponse(map[string]any{"nodes": "not-a-list"}), + wantErrorText: "failed to fetch issue field values", + wantObservedGQLError: true, + }, + { + name: "network error", + gqlHTTPClient: &http.Client{Transport: &errorGraphQLTransport{err: fmt.Errorf("connection reset")}}, + wantErrorText: "connection reset", + wantObservedGQLError: true, + }, + { + name: "GraphQL client construction failure", + gqlClientError: "could not construct GraphQL client", + wantErrorText: "could not construct GraphQL client", }, } - restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), - }) - - gqlVars := map[string]any{ - "ids": []any{"I_node_42"}, - } - gqlResponse := githubv4mock.ErrorResponse("Field 'issueFieldValues' doesn't exist on type 'Issue'") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Bug: Something is broken"), + Body: github.Ptr("Details"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + NodeID: github.Ptr("I_node_42"), + User: &github.User{Login: github.Ptr("user1")}, + IssueFieldValues: []*github.IssueFieldValue{ + {IssueFieldID: 99, DataType: "text", Value: "raw REST value"}, + }, + }, + }, + } + restHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }) - const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" - matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + var deps ToolDependencies + if tt.gqlClientError != "" { + deps = stubDeps{ + clientFn: stubClientFnFromHTTP(t, restHTTPClient), + gqlClientFn: stubGQLClientFnErr(tt.gqlClientError), + obsv: stubExporters(), + } + } else { + gqlHTTPClient := tt.gqlHTTPClient + if gqlHTTPClient == nil { + matcher := githubv4mock.NewQueryMatcher(searchIssueFieldValuesQueryString, gqlVars, tt.gqlResponse) + gqlHTTPClient = githubv4mock.NewMockedHTTPClient(matcher) + } + deps = BaseDeps{ + Client: mustNewGHClient(t, restHTTPClient), + GQLClient: githubv4.NewClient(gqlHTTPClient), + } + } - deps := BaseDeps{ - Client: mustNewGHClient(t, restClient), - GQLClient: gqlClient, - } - handler := serverTool.Handler(deps) + requestArgs := map[string]any{"query": "repo:owner/repo is:open"} + if tt.useFieldsFilter { + requestArgs["fields"] = []any{"number", "title", "state", "field_values"} + } + request := createMCPRequest(requestArgs) + ctx := ghErrors.ContextWithGitHubErrors(context.Background()) + ctx = ContextWithDeps(ctx, deps) + result, err := serverTool.Handler(deps)(ctx, &request) + require.NoError(t, err) - request := createMCPRequest(map[string]any{ - "query": "repo:owner/repo is:open", - }) + observedErrors, err := ghErrors.GetGitHubGraphQLErrors(ctx) + require.NoError(t, err) + if tt.wantObservedGQLError { + require.Len(t, observedErrors, 1) + assert.Equal(t, "failed to search issues: failed to fetch issue field values", observedErrors[0].Message) + } else { + assert.Empty(t, observedErrors) + } - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, "expected result to not be an error") + if tt.wantErrorText != "" { + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tt.wantErrorText) + return + } - textContent := getTextResult(t, result) + require.False(t, result.IsError, getTextResult(t, result).Text) + var response struct { + Total *int `json:"total_count"` + Items []map[string]json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + require.Equal(t, 1, *response.Total) + require.Len(t, response.Items, 1) + item := response.Items[0] + assert.Contains(t, item, "number") + assert.Contains(t, item, "title") + assert.Contains(t, item, "state") + assert.NotContains(t, item, "issue_field_values") + if tt.useFieldsFilter { + assert.NotContains(t, item, "body") + assert.NotContains(t, item, "html_url") + assert.NotContains(t, item, "user") + } else { + assert.Contains(t, item, "body") + assert.Contains(t, item, "html_url") + assert.Contains(t, item, "user") + } - var response SearchIssuesResponse - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) - require.Equal(t, 1, *response.Total) - require.Len(t, response.Items, 1) - assert.Equal(t, 42, *response.Items[0].Number) - assert.Empty(t, response.Items[0].FieldValues) + if tt.wantFieldValues { + var fieldValues []MinimalFieldValue + require.NoError(t, json.Unmarshal(item["field_values"], &fieldValues)) + assert.Equal(t, []MinimalFieldValue{ + {Field: "priority", Value: "P1"}, + {Field: "estimate", Value: "2.5"}, + }, fieldValues) + if tt.useFieldsFilter { + assert.Len(t, item, 4) + } + } else { + assert.NotContains(t, item, "field_values") + if tt.useFieldsFilter { + assert.Len(t, item, 3) + } + } + }) + } } func Test_CreateIssue(t *testing.T) {