Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1029,11 +1029,138 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues")
result, err := searchIssuesHandler(ctx, deps, args)
return result, nil, err
})
}

// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query.
type SearchIssueResult struct {
*github.Issue
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
}

// SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values
// per item, sourced from a single GraphQL nodes() round-trip.
type SearchIssuesResponse struct {
Total *int `json:"total_count,omitempty"`
IncompleteResults *bool `json:"incomplete_results,omitempty"`
Items []SearchIssueResult `json:"items"`
}

// searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve
// each issue's custom field values in a single GraphQL request.
type searchIssuesNodesQuery struct {
Nodes []struct {
Issue struct {
ID githubv4.ID
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"... on Issue"`
} `graphql:"nodes(ids: $ids)"`
}

// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and
// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and
// an empty result set short-circuits the round-trip.
func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) {
ids := make([]githubv4.ID, 0, len(issues))
for _, iss := range issues {
if iss == nil || iss.NodeID == nil || *iss.NodeID == "" {
continue
}
ids = append(ids, githubv4.ID(*iss.NodeID))
}
if len(ids) == 0 {
return nil, nil
}

var q searchIssuesNodesQuery
if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil {
return nil, err
}

result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes))
for _, n := range q.Nodes {
idStr, ok := n.Issue.ID.(string)
if !ok || idStr == "" {
continue
}
vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
for _, fv := range n.Issue.IssueFieldValues.Nodes {
if m, ok := fragmentToMinimalIssueFieldValue(fv); ok {
vals = append(vals, m)
}
}
result[idStr] = vals
}
return result, nil
}

// searchIssuesHandler runs the REST issues search and enriches each hit with custom field values
// fetched via a single follow-up GraphQL nodes() query.
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, error) {
const errorPrefix = "failed to search issues"

query, opts, err := prepareSearchArgs(args, "issue")
if err != nil {
return utils.NewToolResultError(err.Error()), nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil
}
result, resp, err := client.Search.Issues(ctx, query, opts)
if err != nil {
return utils.NewToolResultErrorFromErr(errorPrefix, err), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil
}

var fieldValuesByID map[string][]MinimalIssueFieldValue
if len(result.Issues) > 0 {
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil
}
fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil
}
}

items := make([]SearchIssueResult, 0, len(result.Issues))
for _, iss := range result.Issues {
hit := SearchIssueResult{Issue: iss}
if iss != nil && iss.NodeID != nil {
hit.FieldValues = fieldValuesByID[*iss.NodeID]
}
items = append(items, hit)
}

response := SearchIssuesResponse{
Total: result.Total,
IncompleteResults: result.IncompleteResults,
Items: items,
}

r, err := json.Marshal(response)
if err != nil {
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil
}

return utils.NewToolResultText(string(r)), nil
}

// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository.
// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource.
const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write"
Expand Down
94 changes: 94 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,100 @@ func Test_SearchIssues(t *testing.T) {
}
}

func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) {
serverTool := SearchIssues(translations.NullTranslationHelper)

mockSearchResult := &github.IssuesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Issues: []*github.Issue{
{
Number: github.Ptr(42),
Title: github.Ptr("Bug: Something is broken"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
NodeID: github.Ptr("I_node_42"),
User: &github.User{Login: github.Ptr("user1")},
},
{
Number: github.Ptr(43),
Title: github.Ptr("Feature request"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
NodeID: github.Ptr("I_node_43"),
User: &github.User{Login: github.Ptr("user2")},
},
},
}

restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
})

gqlVars := map[string]any{
"ids": []any{"I_node_42", "I_node_43"},
}
gqlResponse := githubv4mock.DataResponse(map[string]any{
"nodes": []map[string]any{
{
"id": "I_node_42",
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"name": "priority"},
"value": "P1",
},
{
"__typename": "IssueFieldNumberValue",
"field": map[string]any{"name": "estimate"},
"valueNumber": 2.5,
},
},
},
},
{
"id": "I_node_43",
"issueFieldValues": map[string]any{
"nodes": []map[string]any{},
},
},
},
})

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}}}}}}"
matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))

deps := BaseDeps{
Client: github.NewClient(restClient),
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"query": "repo:owner/repo is:open",
})

result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, "expected result to not be an error")

textContent := getTextResult(t, result)

var response SearchIssuesResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
require.Equal(t, 2, *response.Total)
require.Len(t, response.Items, 2)
assert.Equal(t, 42, *response.Items[0].Number)
assert.Equal(t, []MinimalIssueFieldValue{
{Field: "priority", Value: "P1"},
{Field: "estimate", Value: "2.5"},
}, response.Items[0].FieldValues)
assert.Equal(t, 43, *response.Items[1].Number)
assert.Empty(t, response.Items[1].FieldValues)
}

func Test_CreateIssue(t *testing.T) {
// Verify tool definition once
serverTool := IssueWrite(translations.NullTranslationHelper)
Expand Down
39 changes: 24 additions & 15 deletions pkg/github/search_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,13 @@ func hasTypeFilter(query string) bool {
return hasFilter(query, "type")
}

func searchHandler(
ctx context.Context,
getClient GetClientFn,
args map[string]any,
searchType string,
errorPrefix string,
) (*mcp.CallToolResult, error) {
// prepareSearchArgs resolves the search query string and REST search options from the tool args,
// applying the standard is:<type> / repo:<owner>/<repo> munging shared by search_issues and
// search_pull_requests.
func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) {
query, err := RequiredParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil
return "", nil, err
}

if !hasSpecificFilter(query, "is", searchType) {
Expand All @@ -55,12 +52,12 @@ func searchHandler(

owner, err := OptionalParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil
return "", nil, err
}

repo, err := OptionalParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil
return "", nil, err
}

if owner != "" && repo != "" && !hasRepoFilter(query) {
Expand All @@ -69,25 +66,37 @@ func searchHandler(

sort, err := OptionalParam[string](args, "sort")
if err != nil {
return utils.NewToolResultError(err.Error()), nil
return "", nil, err
}
order, err := OptionalParam[string](args, "order")
if err != nil {
return utils.NewToolResultError(err.Error()), nil
return "", nil, err
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil
return "", nil, err
}

opts := &github.SearchOptions{
// Default to "created" if no sort is provided, as it's a common use case.
return query, &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
Page: pagination.Page,
PerPage: pagination.PerPage,
},
}, nil
}

func searchHandler(
ctx context.Context,
getClient GetClientFn,
args map[string]any,
searchType string,
errorPrefix string,
) (*mcp.CallToolResult, error) {
query, opts, err := prepareSearchArgs(args, searchType)
if err != nil {
return utils.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
Expand Down
Loading