Skip to content

Commit 856ce87

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 389c1e3 commit 856ce87

3 files changed

Lines changed: 250 additions & 40 deletions

File tree

pkg/github/issues.go

Lines changed: 132 additions & 5 deletions
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
@@ -1001,6 +1027,111 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
10011027
return utils.NewToolResultText(string(r)), nil
10021028
}
10031029

1030+
// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and
1031+
// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and
1032+
// an empty result set short-circuits the round-trip.
1033+
func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) {
1034+
ids := make([]githubv4.ID, 0, len(issues))
1035+
for _, iss := range issues {
1036+
if iss == nil || iss.NodeID == nil || *iss.NodeID == "" {
1037+
continue
1038+
}
1039+
ids = append(ids, githubv4.ID(*iss.NodeID))
1040+
}
1041+
if len(ids) == 0 {
1042+
return nil, nil
1043+
}
1044+
1045+
var q searchIssuesNodesQuery
1046+
if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil {
1047+
return nil, err
1048+
}
1049+
1050+
result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes))
1051+
for _, n := range q.Nodes {
1052+
idStr, ok := n.Issue.ID.(string)
1053+
if !ok || idStr == "" {
1054+
continue
1055+
}
1056+
vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
1057+
for _, fv := range n.Issue.IssueFieldValues.Nodes {
1058+
if m, ok := fragmentToMinimalIssueFieldValue(fv); ok {
1059+
vals = append(vals, m)
1060+
}
1061+
}
1062+
result[idStr] = vals
1063+
}
1064+
return result, nil
1065+
}
1066+
1067+
// searchIssuesHandler runs the REST issues search and enriches each hit with custom field values
1068+
// fetched via a single follow-up GraphQL nodes() query.
1069+
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, error) {
1070+
const errorPrefix = "failed to search issues"
1071+
1072+
query, opts, err := prepareSearchArgs(args, "issue")
1073+
if err != nil {
1074+
return utils.NewToolResultError(err.Error()), nil
1075+
}
1076+
1077+
client, err := deps.GetClient(ctx)
1078+
if err != nil {
1079+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil
1080+
}
1081+
result, resp, err := client.Search.Issues(ctx, query, opts)
1082+
if err != nil {
1083+
return utils.NewToolResultErrorFromErr(errorPrefix, err), nil
1084+
}
1085+
defer func() { _ = resp.Body.Close() }()
1086+
1087+
if resp.StatusCode != http.StatusOK {
1088+
body, err := io.ReadAll(resp.Body)
1089+
if err != nil {
1090+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil
1091+
}
1092+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil
1093+
}
1094+
1095+
var fieldValuesByID map[string][]MinimalIssueFieldValue
1096+
if len(result.Issues) > 0 {
1097+
gqlClient, err := deps.GetGQLClient(ctx)
1098+
if err != nil {
1099+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil
1100+
}
1101+
fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues)
1102+
if err != nil {
1103+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil
1104+
}
1105+
}
1106+
1107+
items := make([]SearchIssueResult, 0, len(result.Issues))
1108+
for _, iss := range result.Issues {
1109+
hit := SearchIssueResult{Issue: iss}
1110+
if iss != nil && iss.NodeID != nil {
1111+
hit.FieldValues = fieldValuesByID[*iss.NodeID]
1112+
}
1113+
items = append(items, hit)
1114+
}
1115+
1116+
response := SearchIssuesResponse{
1117+
Total: result.Total,
1118+
IncompleteResults: result.IncompleteResults,
1119+
Items: items,
1120+
}
1121+
1122+
r, err := json.Marshal(response)
1123+
if err != nil {
1124+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil
1125+
}
1126+
1127+
callResult := utils.NewToolResultText(string(r))
1128+
if deps.GetFlags(ctx).InsidersMode {
1129+
fn := searchIssuesIFCPostProcess(deps)
1130+
fn(ctx, result, callResult)
1131+
}
1132+
return callResult, nil
1133+
}
1134+
10041135
// SearchIssues creates a tool to search for issues.
10051136
func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
10061137
schema := &jsonschema.Schema{
@@ -1058,11 +1189,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
10581189
},
10591190
[]scopes.Scope{scopes.Repo},
10601191
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1061-
var options []searchOption
1062-
if deps.GetFlags(ctx).InsidersMode {
1063-
options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps)))
1064-
}
1065-
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...)
1192+
result, err := searchIssuesHandler(ctx, deps, args)
10661193
return result, nil, err
10671194
})
10681195
}

pkg/github/issues_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,100 @@ func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any {
10501050
return ifcMap
10511051
}
10521052

1053+
func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) {
1054+
serverTool := SearchIssues(translations.NullTranslationHelper)
1055+
1056+
mockSearchResult := &github.IssuesSearchResult{
1057+
Total: github.Ptr(2),
1058+
IncompleteResults: github.Ptr(false),
1059+
Issues: []*github.Issue{
1060+
{
1061+
Number: github.Ptr(42),
1062+
Title: github.Ptr("Bug: Something is broken"),
1063+
State: github.Ptr("open"),
1064+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
1065+
NodeID: github.Ptr("I_node_42"),
1066+
User: &github.User{Login: github.Ptr("user1")},
1067+
},
1068+
{
1069+
Number: github.Ptr(43),
1070+
Title: github.Ptr("Feature request"),
1071+
State: github.Ptr("open"),
1072+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
1073+
NodeID: github.Ptr("I_node_43"),
1074+
User: &github.User{Login: github.Ptr("user2")},
1075+
},
1076+
},
1077+
}
1078+
1079+
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1080+
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
1081+
})
1082+
1083+
gqlVars := map[string]any{
1084+
"ids": []any{"I_node_42", "I_node_43"},
1085+
}
1086+
gqlResponse := githubv4mock.DataResponse(map[string]any{
1087+
"nodes": []map[string]any{
1088+
{
1089+
"id": "I_node_42",
1090+
"issueFieldValues": map[string]any{
1091+
"nodes": []map[string]any{
1092+
{
1093+
"__typename": "IssueFieldSingleSelectValue",
1094+
"field": map[string]any{"name": "priority"},
1095+
"value": "P1",
1096+
},
1097+
{
1098+
"__typename": "IssueFieldNumberValue",
1099+
"field": map[string]any{"name": "estimate"},
1100+
"valueNumber": 2.5,
1101+
},
1102+
},
1103+
},
1104+
},
1105+
{
1106+
"id": "I_node_43",
1107+
"issueFieldValues": map[string]any{
1108+
"nodes": []map[string]any{},
1109+
},
1110+
},
1111+
},
1112+
})
1113+
1114+
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}}}}}}"
1115+
matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse)
1116+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
1117+
1118+
deps := BaseDeps{
1119+
Client: github.NewClient(restClient),
1120+
GQLClient: gqlClient,
1121+
}
1122+
handler := serverTool.Handler(deps)
1123+
1124+
request := createMCPRequest(map[string]any{
1125+
"query": "repo:owner/repo is:open",
1126+
})
1127+
1128+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1129+
require.NoError(t, err)
1130+
require.False(t, result.IsError, "expected result to not be an error")
1131+
1132+
textContent := getTextResult(t, result)
1133+
1134+
var response SearchIssuesResponse
1135+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
1136+
require.Equal(t, 2, *response.Total)
1137+
require.Len(t, response.Items, 2)
1138+
assert.Equal(t, 42, *response.Items[0].Number)
1139+
assert.Equal(t, []MinimalIssueFieldValue{
1140+
{Field: "priority", Value: "P1"},
1141+
{Field: "estimate", Value: "2.5"},
1142+
}, response.Items[0].FieldValues)
1143+
assert.Equal(t, 43, *response.Items[1].Number)
1144+
assert.Empty(t, response.Items[1].FieldValues)
1145+
}
1146+
10531147
func Test_CreateIssue(t *testing.T) {
10541148
// Verify tool definition once
10551149
serverTool := IssueWrite(translations.NullTranslationHelper)

pkg/github/search_utils.go

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -42,33 +42,13 @@ func hasTypeFilter(query string) bool {
4242
// labels) to the call result based on the search payload.
4343
type searchPostProcessFn func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult)
4444

45-
type searchConfig struct {
46-
postProcess searchPostProcessFn
47-
}
48-
49-
type searchOption func(*searchConfig)
50-
51-
// withSearchPostProcess registers a callback invoked after a successful search
52-
// response. The callback may mutate the call result (e.g. to attach _meta.ifc).
53-
func withSearchPostProcess(fn searchPostProcessFn) searchOption {
54-
return func(c *searchConfig) { c.postProcess = fn }
55-
}
56-
57-
func searchHandler(
58-
ctx context.Context,
59-
getClient GetClientFn,
60-
args map[string]any,
61-
searchType string,
62-
errorPrefix string,
63-
options ...searchOption,
64-
) (*mcp.CallToolResult, error) {
65-
cfg := searchConfig{}
66-
for _, opt := range options {
67-
opt(&cfg)
68-
}
45+
// prepareSearchArgs resolves the search query string and REST search options from the tool args,
46+
// applying the standard is:<type> / repo:<owner>/<repo> query transformations shared by search_issues and
47+
// search_pull_requests.
48+
func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) {
6949
query, err := RequiredParam[string](args, "query")
7050
if err != nil {
71-
return utils.NewToolResultError(err.Error()), nil
51+
return "", nil, err
7252
}
7353

7454
if !hasSpecificFilter(query, "is", searchType) {
@@ -77,12 +57,12 @@ func searchHandler(
7757

7858
owner, err := OptionalParam[string](args, "owner")
7959
if err != nil {
80-
return utils.NewToolResultError(err.Error()), nil
60+
return "", nil, err
8161
}
8262

8363
repo, err := OptionalParam[string](args, "repo")
8464
if err != nil {
85-
return utils.NewToolResultError(err.Error()), nil
65+
return "", nil, err
8666
}
8767

8868
if owner != "" && repo != "" && !hasRepoFilter(query) {
@@ -91,25 +71,37 @@ func searchHandler(
9171

9272
sort, err := OptionalParam[string](args, "sort")
9373
if err != nil {
94-
return utils.NewToolResultError(err.Error()), nil
74+
return "", nil, err
9575
}
9676
order, err := OptionalParam[string](args, "order")
9777
if err != nil {
98-
return utils.NewToolResultError(err.Error()), nil
78+
return "", nil, err
9979
}
10080
pagination, err := OptionalPaginationParams(args)
10181
if err != nil {
102-
return utils.NewToolResultError(err.Error()), nil
82+
return "", nil, err
10383
}
10484

105-
opts := &github.SearchOptions{
106-
// Default to "created" if no sort is provided, as it's a common use case.
85+
return query, &github.SearchOptions{
10786
Sort: sort,
10887
Order: order,
10988
ListOptions: github.ListOptions{
11089
Page: pagination.Page,
11190
PerPage: pagination.PerPage,
11291
},
92+
}, nil
93+
}
94+
95+
func searchHandler(
96+
ctx context.Context,
97+
getClient GetClientFn,
98+
args map[string]any,
99+
searchType string,
100+
errorPrefix string,
101+
) (*mcp.CallToolResult, error) {
102+
query, opts, err := prepareSearchArgs(args, searchType)
103+
if err != nil {
104+
return utils.NewToolResultError(err.Error()), nil
113105
}
114106

115107
client, err := getClient(ctx)
@@ -136,8 +128,5 @@ func searchHandler(
136128
}
137129

138130
callResult := utils.NewToolResultText(string(r))
139-
if cfg.postProcess != nil {
140-
cfg.postProcess(ctx, result, callResult)
141-
}
142131
return callResult, nil
143132
}

0 commit comments

Comments
 (0)