Skip to content

Commit b2a6227

Browse files
authored
Add field_values enrichment to search_issues
1 parent e4309fa commit b2a6227

3 files changed

Lines changed: 259 additions & 30 deletions

File tree

pkg/github/issues.go

Lines changed: 135 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)"` // 25 exceeds the practical max of custom fields per issue in GitHub Projects
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
@@ -986,6 +1012,114 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
9861012
return utils.NewToolResultText(string(r)), nil
9871013
}
9881014

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

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: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -48,27 +48,13 @@ type searchConfig struct {
4848

4949
type searchOption func(*searchConfig)
5050

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-
}
51+
// prepareSearchArgs resolves the search query string and REST search options from the tool args,
52+
// applying the standard is:<type> / repo:<owner>/<repo> query transformations shared by
53+
// searchIssues and searchPullRequests.
54+
func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) {
6955
query, err := RequiredParam[string](args, "query")
7056
if err != nil {
71-
return utils.NewToolResultError(err.Error()), nil
57+
return "", nil, err
7258
}
7359

7460
if !hasSpecificFilter(query, "is", searchType) {
@@ -77,12 +63,12 @@ func searchHandler(
7763

7864
owner, err := OptionalParam[string](args, "owner")
7965
if err != nil {
80-
return utils.NewToolResultError(err.Error()), nil
66+
return "", nil, err
8167
}
8268

8369
repo, err := OptionalParam[string](args, "repo")
8470
if err != nil {
85-
return utils.NewToolResultError(err.Error()), nil
71+
return "", nil, err
8672
}
8773

8874
if owner != "" && repo != "" && !hasRepoFilter(query) {
@@ -91,25 +77,44 @@ func searchHandler(
9177

9278
sort, err := OptionalParam[string](args, "sort")
9379
if err != nil {
94-
return utils.NewToolResultError(err.Error()), nil
80+
return "", nil, err
9581
}
9682
order, err := OptionalParam[string](args, "order")
9783
if err != nil {
98-
return utils.NewToolResultError(err.Error()), nil
84+
return "", nil, err
9985
}
10086
pagination, err := OptionalPaginationParams(args)
10187
if err != nil {
102-
return utils.NewToolResultError(err.Error()), nil
88+
return "", nil, err
10389
}
10490

105-
opts := &github.SearchOptions{
91+
return query, &github.SearchOptions{
10692
// Default to "created" if no sort is provided, as it's a common use case.
10793
Sort: sort,
10894
Order: order,
10995
ListOptions: github.ListOptions{
11096
Page: pagination.Page,
11197
PerPage: pagination.PerPage,
11298
},
99+
}, nil
100+
}
101+
102+
func searchHandler(
103+
ctx context.Context,
104+
getClient GetClientFn,
105+
args map[string]any,
106+
searchType string,
107+
errorPrefix string,
108+
options ...searchOption,
109+
) (*mcp.CallToolResult, error) {
110+
cfg := searchConfig{}
111+
for _, opt := range options {
112+
opt(&cfg)
113+
}
114+
115+
query, opts, err := prepareSearchArgs(args, searchType)
116+
if err != nil {
117+
return utils.NewToolResultError(err.Error()), nil
113118
}
114119

115120
client, err := getClient(ctx)

0 commit comments

Comments
 (0)