Skip to content

Commit dca2621

Browse files
authored
Add field_values to search_issues results
1 parent cc2a957 commit dca2621

3 files changed

Lines changed: 260 additions & 21 deletions

File tree

pkg/github/issues.go

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

pkg/github/issues_test.go

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

978+
func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) {
979+
serverTool := SearchIssues(translations.NullTranslationHelper)
980+
981+
mockSearchResult := &github.IssuesSearchResult{
982+
Total: github.Ptr(2),
983+
IncompleteResults: github.Ptr(false),
984+
Issues: []*github.Issue{
985+
{
986+
Number: github.Ptr(42),
987+
Title: github.Ptr("Bug: Something is broken"),
988+
State: github.Ptr("open"),
989+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
990+
NodeID: github.Ptr("I_node_42"),
991+
User: &github.User{Login: github.Ptr("user1")},
992+
},
993+
{
994+
Number: github.Ptr(43),
995+
Title: github.Ptr("Feature request"),
996+
State: github.Ptr("open"),
997+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
998+
NodeID: github.Ptr("I_node_43"),
999+
User: &github.User{Login: github.Ptr("user2")},
1000+
},
1001+
},
1002+
}
1003+
1004+
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1005+
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
1006+
})
1007+
1008+
gqlVars := map[string]any{
1009+
"ids": []any{"I_node_42", "I_node_43"},
1010+
}
1011+
gqlResponse := githubv4mock.DataResponse(map[string]any{
1012+
"nodes": []map[string]any{
1013+
{
1014+
"id": "I_node_42",
1015+
"issueFieldValues": map[string]any{
1016+
"nodes": []map[string]any{
1017+
{
1018+
"__typename": "IssueFieldSingleSelectValue",
1019+
"field": map[string]any{"name": "priority"},
1020+
"value": "P1",
1021+
},
1022+
{
1023+
"__typename": "IssueFieldNumberValue",
1024+
"field": map[string]any{"name": "estimate"},
1025+
"valueNumber": 2.5,
1026+
},
1027+
},
1028+
},
1029+
},
1030+
{
1031+
"id": "I_node_43",
1032+
"issueFieldValues": map[string]any{
1033+
"nodes": []map[string]any{},
1034+
},
1035+
},
1036+
},
1037+
})
1038+
1039+
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}}}}}}"
1040+
matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse)
1041+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
1042+
1043+
deps := BaseDeps{
1044+
Client: mustNewGHClient(t, restClient),
1045+
GQLClient: gqlClient,
1046+
}
1047+
handler := serverTool.Handler(deps)
1048+
1049+
request := createMCPRequest(map[string]any{
1050+
"query": "repo:owner/repo is:open",
1051+
})
1052+
1053+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1054+
require.NoError(t, err)
1055+
require.False(t, result.IsError, "expected result to not be an error")
1056+
1057+
textContent := getTextResult(t, result)
1058+
1059+
var response SearchIssuesResponse
1060+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
1061+
require.Equal(t, 2, *response.Total)
1062+
require.Len(t, response.Items, 2)
1063+
assert.Equal(t, 42, *response.Items[0].Number)
1064+
assert.Equal(t, []MinimalIssueFieldValue{
1065+
{Field: "priority", Value: "P1"},
1066+
{Field: "estimate", Value: "2.5"},
1067+
}, response.Items[0].FieldValues)
1068+
assert.Equal(t, 43, *response.Items[1].Number)
1069+
assert.Empty(t, response.Items[1].FieldValues)
1070+
}
1071+
9781072
func Test_CreateIssue(t *testing.T) {
9791073
// Verify tool definition once
9801074
serverTool := IssueWrite(translations.NullTranslationHelper)

pkg/github/search_utils.go

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,13 @@ func withSearchPostProcess(fn searchPostProcessFn) searchOption {
5454
return func(c *searchConfig) { c.postProcess = fn }
5555
}
5656

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-
}
57+
// prepareSearchArgs resolves the search query string and REST search options from the tool args,
58+
// applying the standard is:<type> / repo:<owner>/<repo> munging shared by search_issues and
59+
// search_pull_requests.
60+
func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) {
6961
query, err := RequiredParam[string](args, "query")
7062
if err != nil {
71-
return utils.NewToolResultError(err.Error()), nil
63+
return "", nil, err
7264
}
7365

7466
if !hasSpecificFilter(query, "is", searchType) {
@@ -77,12 +69,12 @@ func searchHandler(
7769

7870
owner, err := OptionalParam[string](args, "owner")
7971
if err != nil {
80-
return utils.NewToolResultError(err.Error()), nil
72+
return "", nil, err
8173
}
8274

8375
repo, err := OptionalParam[string](args, "repo")
8476
if err != nil {
85-
return utils.NewToolResultError(err.Error()), nil
77+
return "", nil, err
8678
}
8779

8880
if owner != "" && repo != "" && !hasRepoFilter(query) {
@@ -91,25 +83,42 @@ func searchHandler(
9183

9284
sort, err := OptionalParam[string](args, "sort")
9385
if err != nil {
94-
return utils.NewToolResultError(err.Error()), nil
86+
return "", nil, err
9587
}
9688
order, err := OptionalParam[string](args, "order")
9789
if err != nil {
98-
return utils.NewToolResultError(err.Error()), nil
90+
return "", nil, err
9991
}
10092
pagination, err := OptionalPaginationParams(args)
10193
if err != nil {
102-
return utils.NewToolResultError(err.Error()), nil
94+
return "", nil, err
10395
}
10496

105-
opts := &github.SearchOptions{
106-
// Default to "created" if no sort is provided, as it's a common use case.
97+
return query, &github.SearchOptions{
10798
Sort: sort,
10899
Order: order,
109100
ListOptions: github.ListOptions{
110101
Page: pagination.Page,
111102
PerPage: pagination.PerPage,
112103
},
104+
}, nil
105+
}
106+
107+
func searchHandler(
108+
ctx context.Context,
109+
getClient GetClientFn,
110+
args map[string]any,
111+
searchType string,
112+
errorPrefix string,
113+
options ...searchOption,
114+
) (*mcp.CallToolResult, error) {
115+
cfg := searchConfig{}
116+
for _, opt := range options {
117+
opt(&cfg)
118+
}
119+
query, opts, err := prepareSearchArgs(args, searchType)
120+
if err != nil {
121+
return utils.NewToolResultError(err.Error()), nil
113122
}
114123

115124
client, err := getClient(ctx)

0 commit comments

Comments
 (0)