Skip to content

Commit 8f4680b

Browse files
Add field_values to search_issues results (#2474)
* Add field_values to search_issues results * remove dupe keys * Fix advanced search not enabled for fields --------- Co-authored-by: Sam Morrow <info@sam-morrow.com>
1 parent e953333 commit 8f4680b

3 files changed

Lines changed: 330 additions & 20 deletions

File tree

pkg/github/issues.go

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
10471047
if deps.GetFlags(ctx).InsidersMode {
10481048
options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps)))
10491049
}
1050-
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...)
1050+
result, err := searchIssuesHandler(ctx, deps, args, options...)
10511051
return result, nil, err
10521052
})
10531053
}
@@ -1134,6 +1134,164 @@ func parseRepositoryURL(repoURL string) (string, string, bool) {
11341134
return parts[0], parts[1], true
11351135
}
11361136

1137+
// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query.
1138+
type SearchIssueResult struct {
1139+
*github.Issue
1140+
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
1141+
}
1142+
1143+
// MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the
1144+
// embedded REST response in favour of the normalized field_values populated via GraphQL enrichment.
1145+
func (r SearchIssueResult) MarshalJSON() ([]byte, error) {
1146+
issueBytes, err := json.Marshal(r.Issue)
1147+
if err != nil {
1148+
return nil, err
1149+
}
1150+
var m map[string]json.RawMessage
1151+
if err := json.Unmarshal(issueBytes, &m); err != nil {
1152+
return nil, err
1153+
}
1154+
delete(m, "issue_field_values")
1155+
if r.FieldValues != nil {
1156+
fv, err := json.Marshal(r.FieldValues)
1157+
if err != nil {
1158+
return nil, err
1159+
}
1160+
m["field_values"] = fv
1161+
}
1162+
return json.Marshal(m)
1163+
}
1164+
1165+
// SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values
1166+
// per item, sourced from a single GraphQL nodes() round-trip.
1167+
type SearchIssuesResponse struct {
1168+
Total *int `json:"total_count,omitempty"`
1169+
IncompleteResults *bool `json:"incomplete_results,omitempty"`
1170+
Items []SearchIssueResult `json:"items"`
1171+
}
1172+
1173+
// searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve
1174+
// each issue's custom field values in a single GraphQL request.
1175+
type searchIssuesNodesQuery struct {
1176+
Nodes []struct {
1177+
Issue struct {
1178+
ID githubv4.ID
1179+
IssueFieldValues struct {
1180+
Nodes []IssueFieldValueFragment
1181+
} `graphql:"issueFieldValues(first: 25)"`
1182+
} `graphql:"... on Issue"`
1183+
} `graphql:"nodes(ids: $ids)"`
1184+
}
1185+
1186+
// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and
1187+
// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and
1188+
// an empty result set short-circuits the round-trip.
1189+
func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) {
1190+
ids := make([]githubv4.ID, 0, len(issues))
1191+
for _, iss := range issues {
1192+
if iss == nil || iss.NodeID == nil || *iss.NodeID == "" {
1193+
continue
1194+
}
1195+
ids = append(ids, githubv4.ID(*iss.NodeID))
1196+
}
1197+
if len(ids) == 0 {
1198+
return nil, nil
1199+
}
1200+
1201+
var q searchIssuesNodesQuery
1202+
if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil {
1203+
return nil, err
1204+
}
1205+
1206+
result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes))
1207+
for _, n := range q.Nodes {
1208+
idStr, ok := n.Issue.ID.(string)
1209+
if !ok || idStr == "" {
1210+
continue
1211+
}
1212+
vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
1213+
for _, fv := range n.Issue.IssueFieldValues.Nodes {
1214+
if m, ok := fragmentToMinimalIssueFieldValue(fv); ok {
1215+
vals = append(vals, m)
1216+
}
1217+
}
1218+
result[idStr] = vals
1219+
}
1220+
return result, nil
1221+
}
1222+
1223+
// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values
1224+
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
1225+
// (e.g. IFC labelling).
1226+
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) {
1227+
const errorPrefix = "failed to search issues"
1228+
1229+
query, opts, err := prepareSearchArgs(args, "issue")
1230+
if err != nil {
1231+
return utils.NewToolResultError(err.Error()), nil
1232+
}
1233+
1234+
client, err := deps.GetClient(ctx)
1235+
if err != nil {
1236+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil
1237+
}
1238+
result, resp, err := client.Search.Issues(ctx, query, opts)
1239+
if err != nil {
1240+
return utils.NewToolResultErrorFromErr(errorPrefix, err), nil
1241+
}
1242+
defer func() { _ = resp.Body.Close() }()
1243+
1244+
if resp.StatusCode != http.StatusOK {
1245+
body, err := io.ReadAll(resp.Body)
1246+
if err != nil {
1247+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil
1248+
}
1249+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil
1250+
}
1251+
1252+
var fieldValuesByID map[string][]MinimalIssueFieldValue
1253+
if len(result.Issues) > 0 {
1254+
gqlClient, err := deps.GetGQLClient(ctx)
1255+
if err != nil {
1256+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil
1257+
}
1258+
fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues)
1259+
if err != nil {
1260+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil
1261+
}
1262+
}
1263+
1264+
items := make([]SearchIssueResult, 0, len(result.Issues))
1265+
for _, iss := range result.Issues {
1266+
hit := SearchIssueResult{Issue: iss}
1267+
if iss != nil && iss.NodeID != nil {
1268+
hit.FieldValues = fieldValuesByID[*iss.NodeID]
1269+
}
1270+
items = append(items, hit)
1271+
}
1272+
1273+
response := SearchIssuesResponse{
1274+
Total: result.Total,
1275+
IncompleteResults: result.IncompleteResults,
1276+
Items: items,
1277+
}
1278+
1279+
r, err := json.Marshal(response)
1280+
if err != nil {
1281+
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil
1282+
}
1283+
1284+
callResult := utils.NewToolResultText(string(r))
1285+
cfg := searchConfig{}
1286+
for _, opt := range options {
1287+
opt(&cfg)
1288+
}
1289+
if cfg.postProcess != nil {
1290+
cfg.postProcess(ctx, result, callResult)
1291+
}
1292+
return callResult, nil
1293+
}
1294+
11371295
// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository.
11381296
// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource.
11391297
const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write"

pkg/github/issues_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,47 @@ func Test_SearchIssues(t *testing.T) {
744744
expectError: false,
745745
expectedResult: mockSearchResult,
746746
},
747+
{
748+
name: "query with field. qualifier enables advanced_search",
749+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
750+
GetSearchIssues: expectQueryParams(
751+
t,
752+
map[string]string{
753+
"q": "is:issue field.priority:P1",
754+
"page": "1",
755+
"per_page": "30",
756+
"advanced_search": "true",
757+
},
758+
).andThen(
759+
mockResponse(t, http.StatusOK, mockSearchResult),
760+
),
761+
}),
762+
requestArgs: map[string]any{
763+
"query": "field.priority:P1",
764+
},
765+
expectError: false,
766+
expectedResult: mockSearchResult,
767+
},
768+
{
769+
name: "query without field. qualifier does not set advanced_search",
770+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
771+
GetSearchIssues: expectQueryParams(
772+
t,
773+
map[string]string{
774+
"q": "is:issue is:open",
775+
"page": "1",
776+
"per_page": "30",
777+
},
778+
).andThen(
779+
mockResponse(t, http.StatusOK, mockSearchResult),
780+
),
781+
}),
782+
requestArgs: map[string]any{
783+
"query": "is:open",
784+
},
785+
expectError: false,
786+
expectedResult: mockSearchResult,
787+
},
747788
{
748789
name: "search issues fails",
749790
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
@@ -975,6 +1016,100 @@ func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any {
9751016
return ifcMap
9761017
}
9771018

1019+
func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) {
1020+
serverTool := SearchIssues(translations.NullTranslationHelper)
1021+
1022+
mockSearchResult := &github.IssuesSearchResult{
1023+
Total: github.Ptr(2),
1024+
IncompleteResults: github.Ptr(false),
1025+
Issues: []*github.Issue{
1026+
{
1027+
Number: github.Ptr(42),
1028+
Title: github.Ptr("Bug: Something is broken"),
1029+
State: github.Ptr("open"),
1030+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
1031+
NodeID: github.Ptr("I_node_42"),
1032+
User: &github.User{Login: github.Ptr("user1")},
1033+
},
1034+
{
1035+
Number: github.Ptr(43),
1036+
Title: github.Ptr("Feature request"),
1037+
State: github.Ptr("open"),
1038+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
1039+
NodeID: github.Ptr("I_node_43"),
1040+
User: &github.User{Login: github.Ptr("user2")},
1041+
},
1042+
},
1043+
}
1044+
1045+
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1046+
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
1047+
})
1048+
1049+
gqlVars := map[string]any{
1050+
"ids": []any{"I_node_42", "I_node_43"},
1051+
}
1052+
gqlResponse := githubv4mock.DataResponse(map[string]any{
1053+
"nodes": []map[string]any{
1054+
{
1055+
"id": "I_node_42",
1056+
"issueFieldValues": map[string]any{
1057+
"nodes": []map[string]any{
1058+
{
1059+
"__typename": "IssueFieldSingleSelectValue",
1060+
"field": map[string]any{"name": "priority"},
1061+
"value": "P1",
1062+
},
1063+
{
1064+
"__typename": "IssueFieldNumberValue",
1065+
"field": map[string]any{"name": "estimate"},
1066+
"valueNumber": 2.5,
1067+
},
1068+
},
1069+
},
1070+
},
1071+
{
1072+
"id": "I_node_43",
1073+
"issueFieldValues": map[string]any{
1074+
"nodes": []map[string]any{},
1075+
},
1076+
},
1077+
},
1078+
})
1079+
1080+
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}}}}}}"
1081+
matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse)
1082+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
1083+
1084+
deps := BaseDeps{
1085+
Client: mustNewGHClient(t, restClient),
1086+
GQLClient: gqlClient,
1087+
}
1088+
handler := serverTool.Handler(deps)
1089+
1090+
request := createMCPRequest(map[string]any{
1091+
"query": "repo:owner/repo is:open",
1092+
})
1093+
1094+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1095+
require.NoError(t, err)
1096+
require.False(t, result.IsError, "expected result to not be an error")
1097+
1098+
textContent := getTextResult(t, result)
1099+
1100+
var response SearchIssuesResponse
1101+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
1102+
require.Equal(t, 2, *response.Total)
1103+
require.Len(t, response.Items, 2)
1104+
assert.Equal(t, 42, *response.Items[0].Number)
1105+
assert.Equal(t, []MinimalIssueFieldValue{
1106+
{Field: "priority", Value: "P1"},
1107+
{Field: "estimate", Value: "2.5"},
1108+
}, response.Items[0].FieldValues)
1109+
assert.Equal(t, 43, *response.Items[1].Number)
1110+
assert.Empty(t, response.Items[1].FieldValues)
1111+
}
1112+
9781113
func Test_CreateIssue(t *testing.T) {
9791114
// Verify tool definition once
9801115
serverTool := IssueWrite(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)