Skip to content
Merged
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
160 changes: 159 additions & 1 deletion pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
if deps.GetFlags(ctx).InsidersMode {
options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps)))
}
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...)
result, err := searchIssuesHandler(ctx, deps, args, options...)
return result, nil, err
})
}
Expand Down Expand Up @@ -1134,6 +1134,164 @@ func parseRepositoryURL(repoURL string) (string, string, bool) {
return parts[0], parts[1], true
}

// 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"`
}

// MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the
// embedded REST response in favour of the normalized field_values populated via GraphQL enrichment.
func (r SearchIssueResult) MarshalJSON() ([]byte, error) {
issueBytes, err := json.Marshal(r.Issue)
if err != nil {
return nil, err
}
var m map[string]json.RawMessage
if err := json.Unmarshal(issueBytes, &m); err != nil {
return nil, err
}
delete(m, "issue_field_values")
if r.FieldValues != nil {
fv, err := json.Marshal(r.FieldValues)
if err != nil {
return nil, err
}
m["field_values"] = fv
}
return json.Marshal(m)
}

// 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, enriches each hit with custom field values
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
// (e.g. IFC labelling).
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*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
}

callResult := utils.NewToolResultText(string(r))
cfg := searchConfig{}
for _, opt := range options {
opt(&cfg)
}
if cfg.postProcess != nil {
cfg.postProcess(ctx, result, callResult)
}
return callResult, 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
135 changes: 135 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,47 @@ func Test_SearchIssues(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with field. qualifier enables advanced_search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue field.priority:P1",
"page": "1",
"per_page": "30",
"advanced_search": "true",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "field.priority:P1",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query without field. qualifier does not set advanced_search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue is:open",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search issues fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
Expand Down Expand Up @@ -975,6 +1016,100 @@ func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any {
return ifcMap
}

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: mustNewGHClient(t, 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
Loading
Loading