Skip to content

Commit 900d2b9

Browse files
authored
Add field_values enrichment to search_issues via GraphQL nodes() query
After the REST search returns results, batch a single nodes(ids:[...]) GraphQL query to fetch each issue's custom field values. The extra round-trip is one per page of results. Non-breaking: field_values is omitempty and the response shape is additive. Also extracts prepareSearchArgs from searchHandler so the query-building logic is shared with search_pull_requests without coupling PR search to the issue-specific enrichment path.
1 parent 0fccfb0 commit 900d2b9

3 files changed

Lines changed: 245 additions & 16 deletions

File tree

pkg/github/issues.go

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,32 @@ type ListIssuesQueryTypeWithLabelsWithSince struct {
228228
} `graphql:"repository(owner: $owner, name: $repo)"`
229229
}
230230

231+
// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query.
232+
type SearchIssueResult struct {
233+
*github.Issue
234+
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
235+
}
236+
237+
// SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values per item.
238+
type SearchIssuesResponse struct {
239+
Total *int `json:"total_count,omitempty"`
240+
IncompleteResults *bool `json:"incomplete_results,omitempty"`
241+
Items []SearchIssueResult `json:"items"`
242+
}
243+
244+
// searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve
245+
// each issue's custom field values in a single GraphQL request.
246+
type searchIssuesNodesQuery struct {
247+
Nodes []struct {
248+
Issue struct {
249+
ID githubv4.ID
250+
IssueFieldValues struct {
251+
Nodes []IssueFieldValueFragment
252+
} `graphql:"issueFieldValues(first: 25)"`
253+
} `graphql:"... on Issue"`
254+
} `graphql:"nodes(ids: $ids)"`
255+
}
256+
231257
// Implement the interface for all query types
232258
func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment {
233259
return q.Repository.Issues
@@ -972,6 +998,106 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
972998
return utils.NewToolResultText(string(r)), nil
973999
}
9741000

1001+
// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and
1002+
// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and
1003+
// an empty result set short-circuits the round-trip.
1004+
func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) {
1005+
ids := make([]githubv4.ID, 0, len(issues))
1006+
for _, iss := range issues {
1007+
if iss == nil || iss.NodeID == nil || *iss.NodeID == "" {
1008+
continue
1009+
}
1010+
ids = append(ids, githubv4.ID(*iss.NodeID))
1011+
}
1012+
if len(ids) == 0 {
1013+
return nil, nil
1014+
}
1015+
1016+
var q searchIssuesNodesQuery
1017+
if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil {
1018+
return nil, err
1019+
}
1020+
1021+
result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes))
1022+
for _, n := range q.Nodes {
1023+
idStr, ok := n.Issue.ID.(string)
1024+
if !ok || idStr == "" {
1025+
continue
1026+
}
1027+
vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
1028+
for _, fv := range n.Issue.IssueFieldValues.Nodes {
1029+
if m, ok := fragmentToMinimalIssueFieldValue(fv); ok {
1030+
vals = append(vals, m)
1031+
}
1032+
}
1033+
result[idStr] = vals
1034+
}
1035+
return result, nil
1036+
}
1037+
1038+
// searchIssuesHandler runs the REST issues search and enriches each hit with custom field values
1039+
// fetched via a single follow-up GraphQL nodes() query.
1040+
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, error) {
1041+
const errorPrefix = "failed to search issues"
1042+
1043+
query, opts, err := prepareSearchArgs(args, "issue")
1044+
if err != nil {
1045+
return utils.NewToolResultError(err.Error()), nil
1046+
}
1047+
1048+
client, err := deps.GetClient(ctx)
1049+
if err != nil {
1050+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil
1051+
}
1052+
result, resp, err := client.Search.Issues(ctx, query, opts)
1053+
if err != nil {
1054+
return utils.NewToolResultErrorFromErr(errorPrefix, err), nil
1055+
}
1056+
defer func() { _ = resp.Body.Close() }()
1057+
1058+
if resp.StatusCode != http.StatusOK {
1059+
body, err := io.ReadAll(resp.Body)
1060+
if err != nil {
1061+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil
1062+
}
1063+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil
1064+
}
1065+
1066+
var fieldValuesByID map[string][]MinimalIssueFieldValue
1067+
if len(result.Issues) > 0 {
1068+
gqlClient, err := deps.GetGQLClient(ctx)
1069+
if err != nil {
1070+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil
1071+
}
1072+
fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues)
1073+
if err != nil {
1074+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil
1075+
}
1076+
}
1077+
1078+
items := make([]SearchIssueResult, 0, len(result.Issues))
1079+
for _, iss := range result.Issues {
1080+
hit := SearchIssueResult{Issue: iss}
1081+
if iss != nil && iss.NodeID != nil {
1082+
hit.FieldValues = fieldValuesByID[*iss.NodeID]
1083+
}
1084+
items = append(items, hit)
1085+
}
1086+
1087+
response := SearchIssuesResponse{
1088+
Total: result.Total,
1089+
IncompleteResults: result.IncompleteResults,
1090+
Items: items,
1091+
}
1092+
1093+
r, err := json.Marshal(response)
1094+
if err != nil {
1095+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil
1096+
}
1097+
1098+
return utils.NewToolResultText(string(r)), nil
1099+
}
1100+
9751101
// SearchIssues creates a tool to search for issues.
9761102
func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
9771103
schema := &jsonschema.Schema{
@@ -1029,7 +1155,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
10291155
},
10301156
[]scopes.Scope{scopes.Repo},
10311157
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1032-
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues")
1158+
result, err := searchIssuesHandler(ctx, deps, args)
10331159
return result, nil, err
10341160
})
10351161
}

pkg/github/issues_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,100 @@ func Test_SearchIssues(t *testing.T) {
693693
}
694694
}
695695

696+
func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) {
697+
serverTool := SearchIssues(translations.NullTranslationHelper)
698+
699+
mockSearchResult := &github.IssuesSearchResult{
700+
Total: github.Ptr(2),
701+
IncompleteResults: github.Ptr(false),
702+
Issues: []*github.Issue{
703+
{
704+
Number: github.Ptr(42),
705+
Title: github.Ptr("Bug: Something is broken"),
706+
State: github.Ptr("open"),
707+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
708+
NodeID: github.Ptr("I_node_42"),
709+
User: &github.User{Login: github.Ptr("user1")},
710+
},
711+
{
712+
Number: github.Ptr(43),
713+
Title: github.Ptr("Feature request"),
714+
State: github.Ptr("open"),
715+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
716+
NodeID: github.Ptr("I_node_43"),
717+
User: &github.User{Login: github.Ptr("user2")},
718+
},
719+
},
720+
}
721+
722+
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
723+
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
724+
})
725+
726+
gqlVars := map[string]any{
727+
"ids": []any{"I_node_42", "I_node_43"},
728+
}
729+
gqlResponse := githubv4mock.DataResponse(map[string]any{
730+
"nodes": []map[string]any{
731+
{
732+
"id": "I_node_42",
733+
"issueFieldValues": map[string]any{
734+
"nodes": []map[string]any{
735+
{
736+
"__typename": "IssueFieldSingleSelectValue",
737+
"field": map[string]any{"name": "priority"},
738+
"value": "P1",
739+
},
740+
{
741+
"__typename": "IssueFieldNumberValue",
742+
"field": map[string]any{"name": "estimate"},
743+
"valueNumber": 2.5,
744+
},
745+
},
746+
},
747+
},
748+
{
749+
"id": "I_node_43",
750+
"issueFieldValues": map[string]any{
751+
"nodes": []map[string]any{},
752+
},
753+
},
754+
},
755+
})
756+
757+
const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}}}}"
758+
matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse)
759+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
760+
761+
deps := BaseDeps{
762+
Client: github.NewClient(restClient),
763+
GQLClient: gqlClient,
764+
}
765+
handler := serverTool.Handler(deps)
766+
767+
request := createMCPRequest(map[string]any{
768+
"query": "repo:owner/repo is:open",
769+
})
770+
771+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
772+
require.NoError(t, err)
773+
require.False(t, result.IsError, "expected result to not be an error")
774+
775+
textContent := getTextResult(t, result)
776+
777+
var response SearchIssuesResponse
778+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
779+
require.Equal(t, 2, *response.Total)
780+
require.Len(t, response.Items, 2)
781+
assert.Equal(t, 42, *response.Items[0].Number)
782+
assert.Equal(t, []MinimalIssueFieldValue{
783+
{Field: "priority", Value: "P1"},
784+
{Field: "estimate", Value: "2.5"},
785+
}, response.Items[0].FieldValues)
786+
assert.Equal(t, 43, *response.Items[1].Number)
787+
assert.Empty(t, response.Items[1].FieldValues)
788+
}
789+
696790
func Test_CreateIssue(t *testing.T) {
697791
// Verify tool definition once
698792
serverTool := IssueWrite(translations.NullTranslationHelper)

pkg/github/search_utils.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,13 @@ func hasTypeFilter(query string) bool {
3737
return hasFilter(query, "type")
3838
}
3939

40-
func searchHandler(
41-
ctx context.Context,
42-
getClient GetClientFn,
43-
args map[string]any,
44-
searchType string,
45-
errorPrefix string,
46-
) (*mcp.CallToolResult, error) {
40+
// prepareSearchArgs resolves the search query string and REST search options from the tool args,
41+
// applying the standard is:<type> / repo:<owner>/<repo> query transformations shared by search_issues and
42+
// search_pull_requests.
43+
func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) {
4744
query, err := RequiredParam[string](args, "query")
4845
if err != nil {
49-
return utils.NewToolResultError(err.Error()), nil
46+
return "", nil, err
5047
}
5148

5249
if !hasSpecificFilter(query, "is", searchType) {
@@ -55,12 +52,12 @@ func searchHandler(
5552

5653
owner, err := OptionalParam[string](args, "owner")
5754
if err != nil {
58-
return utils.NewToolResultError(err.Error()), nil
55+
return "", nil, err
5956
}
6057

6158
repo, err := OptionalParam[string](args, "repo")
6259
if err != nil {
63-
return utils.NewToolResultError(err.Error()), nil
60+
return "", nil, err
6461
}
6562

6663
if owner != "" && repo != "" && !hasRepoFilter(query) {
@@ -69,25 +66,37 @@ func searchHandler(
6966

7067
sort, err := OptionalParam[string](args, "sort")
7168
if err != nil {
72-
return utils.NewToolResultError(err.Error()), nil
69+
return "", nil, err
7370
}
7471
order, err := OptionalParam[string](args, "order")
7572
if err != nil {
76-
return utils.NewToolResultError(err.Error()), nil
73+
return "", nil, err
7774
}
7875
pagination, err := OptionalPaginationParams(args)
7976
if err != nil {
80-
return utils.NewToolResultError(err.Error()), nil
77+
return "", nil, err
8178
}
8279

83-
opts := &github.SearchOptions{
84-
// Default to "created" if no sort is provided, as it's a common use case.
80+
return query, &github.SearchOptions{
8581
Sort: sort,
8682
Order: order,
8783
ListOptions: github.ListOptions{
8884
Page: pagination.Page,
8985
PerPage: pagination.PerPage,
9086
},
87+
}, nil
88+
}
89+
90+
func searchHandler(
91+
ctx context.Context,
92+
getClient GetClientFn,
93+
args map[string]any,
94+
searchType string,
95+
errorPrefix string,
96+
) (*mcp.CallToolResult, error) {
97+
query, opts, err := prepareSearchArgs(args, searchType)
98+
if err != nil {
99+
return utils.NewToolResultError(err.Error()), nil
91100
}
92101

93102
client, err := getClient(ctx)

0 commit comments

Comments
 (0)