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
188 changes: 183 additions & 5 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,51 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
}
}

// IssueFieldRef resolves the name of an issue field across its concrete types.
type IssueFieldRef struct {
Date struct{ Name githubv4.String } `graphql:"... on IssueFieldDate"`
Number struct{ Name githubv4.String } `graphql:"... on IssueFieldNumber"`
SingleSelect struct{ Name githubv4.String } `graphql:"... on IssueFieldSingleSelect"`
Text struct{ Name githubv4.String } `graphql:"... on IssueFieldText"`
}

// Name returns the populated name from whichever IssueFields union variant the field resolved to.
func (r IssueFieldRef) Name() string {
switch {
case r.Date.Name != "":
return string(r.Date.Name)
case r.Number.Name != "":
return string(r.Number.Name)
case r.SingleSelect.Name != "":
return string(r.SingleSelect.Name)
case r.Text.Name != "":
return string(r.Text.Name)
}
return ""
}

// IssueFieldValueFragment captures the value of a custom issue field.
// The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode.
type IssueFieldValueFragment struct {
TypeName string `graphql:"__typename"`
DateValue struct {
Field IssueFieldRef
Value githubv4.String
} `graphql:"... on IssueFieldDateValue"`
NumberValue struct {
Field IssueFieldRef
Value githubv4.Float `graphql:"valueNumber: value"`
} `graphql:"... on IssueFieldNumberValue"`
SingleSelectValue struct {
Field IssueFieldRef
Value githubv4.String
Comment thread
kelsey-myers marked this conversation as resolved.
} `graphql:"... on IssueFieldSingleSelectValue"`
TextValue struct {
Field IssueFieldRef
Value githubv4.String
} `graphql:"... on IssueFieldTextValue"`
}

// IssueFragment represents a fragment of an issue node in the GraphQL API.
type IssueFragment struct {
Number githubv4.Int
Expand All @@ -126,6 +171,9 @@ type IssueFragment struct {
Comments struct {
TotalCount githubv4.Int
} `graphql:"comments"`
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"` // 25 is the limit set in the monolith
}

// Common interface for all issue query types
Expand Down Expand Up @@ -177,6 +225,32 @@ type ListIssuesQueryTypeWithLabelsWithSince struct {
} `graphql:"repository(owner: $owner, name: $repo)"`
}

// 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.
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)"` // 25 exceeds the practical max of custom fields per issue in GitHub Projects
} `graphql:"... on Issue"`
} `graphql:"nodes(ids: $ids)"`
}

// Implement the interface for all query types
func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment {
return q.Repository.Issues
Expand Down Expand Up @@ -950,6 +1024,114 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
return utils.NewToolResultText(string(r)), nil
}

// 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 {
if n.Issue.ID == nil {
continue
}
idStr := fmt.Sprintf("%v", n.Issue.ID)
if 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
Comment thread
kelsey-myers marked this conversation as resolved.
}
}

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))
if deps.GetFlags(ctx).InsidersMode {
fn := searchIssuesIFCPostProcess(deps)
fn(ctx, result, callResult)
}
return callResult, nil
}

// SearchIssues creates a tool to search for issues.
func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Expand Down Expand Up @@ -1007,11 +1189,7 @@ 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) {
var options []searchOption
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)
return result, nil, err
})
}
Expand Down
Loading
Loading