@@ -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.
11391297const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write"
0 commit comments