diff --git a/pkg/github/issues.go b/pkg/github/issues.go index ab8611afb..974cca307 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -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 + } `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 @@ -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 @@ -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 @@ -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 + } + } + + 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{ @@ -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 }) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index ed92c49ab..d567ac4cb 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1050,6 +1050,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: 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) @@ -1420,6 +1514,15 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 5, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + }, + }, }, { "number": 456, @@ -1438,6 +1541,25 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 3, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldDateValue", + "field": map[string]any{"name": "due"}, + "value": "2026-06-01", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + { + "__typename": "IssueFieldTextValue", + "field": map[string]any{"name": "notes"}, + "value": "needs triage", + }, + }, + }, }, } @@ -1458,6 +1580,9 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 1, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, }, } @@ -1632,8 +1757,9 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + issueFieldValuesSelection := "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}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1704,6 +1830,22 @@ func Test_ListIssues(t *testing.T) { for _, label := range issue.Labels { assert.NotEmpty(t, label, "Label should be a non-empty string") } + + // Field values should be flattened to {field, value} pairs. Issue #123 has a + // SingleSelectValue; issue #456 exercises the Date/Number/Text branches + // (including float formatting); #789 has no field values. + switch issue.Number { + case 123: + assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) + case 456: + assert.Equal(t, []MinimalIssueFieldValue{ + {Field: "due", Value: "2026-06-01"}, + {Field: "estimate", Value: "2.5"}, + {Field: "notes", Value: "needs triage"}, + }, issue.FieldValues) + default: + assert.Empty(t, issue.FieldValues) + } } }) } @@ -1749,7 +1891,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } - query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},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}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" vars := map[string]any{ "owner": "octocat", diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index b1e7c2357..40030e9e8 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,7 @@ package github import ( + "strconv" "time" "github.com/google/go-github/v82/github" @@ -187,26 +188,35 @@ type MinimalReactions struct { // MinimalIssue is the trimmed output type for issue objects to reduce verbosity. type MinimalIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body,omitempty"` - State string `json:"state"` - StateReason string `json:"state_reason,omitempty"` - Draft bool `json:"draft,omitempty"` - Locked bool `json:"locked,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - User *MinimalUser `json:"user,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - Labels []string `json:"labels,omitempty"` - Assignees []string `json:"assignees,omitempty"` - Milestone string `json:"milestone,omitempty"` - Comments int `json:"comments,omitempty"` - Reactions *MinimalReactions `json:"reactions,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - ClosedAt string `json:"closed_at,omitempty"` - ClosedBy string `json:"closed_by,omitempty"` - IssueType string `json:"issue_type,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` + FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` +} + +// MinimalIssueFieldValue is the trimmed output type for a custom issue field value. +// Single-value variants (date, number, single-select, text) populate Value. Values is reserved for multi-select. +type MinimalIssueFieldValue struct { + Field string `json:"field"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` } // MinimalIssuesResponse is the trimmed output for a paginated list of issues. @@ -419,9 +429,43 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { m.Labels = append(m.Labels, string(label.Name)) } + for _, fv := range fragment.IssueFieldValues.Nodes { + if mfv, ok := fragmentToMinimalIssueFieldValue(fv); ok { + m.FieldValues = append(m.FieldValues, mfv) + } + } + return m } +// fragmentToMinimalIssueFieldValue flattens the union value fragment into a single +// {field, value} pair. Returns ok=false if the typename is unrecognised. +func fragmentToMinimalIssueFieldValue(fv IssueFieldValueFragment) (MinimalIssueFieldValue, bool) { + switch fv.TypeName { + case "IssueFieldDateValue": + return MinimalIssueFieldValue{ + Field: fv.DateValue.Field.Name(), + Value: string(fv.DateValue.Value), + }, true + case "IssueFieldNumberValue": + return MinimalIssueFieldValue{ + Field: fv.NumberValue.Field.Name(), + Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64), + }, true + case "IssueFieldSingleSelectValue": + return MinimalIssueFieldValue{ + Field: fv.SingleSelectValue.Field.Name(), + Value: string(fv.SingleSelectValue.Value), + }, true + case "IssueFieldTextValue": + return MinimalIssueFieldValue{ + Field: fv.TextValue.Field.Name(), + Value: string(fv.TextValue.Value), + }, true + } + return MinimalIssueFieldValue{}, false +} + func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse { minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) for _, issue := range fragment.Nodes { diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index a0634d979..8b8975c02 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -42,33 +42,13 @@ func hasTypeFilter(query string) bool { // labels) to the call result based on the search payload. type searchPostProcessFn func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) -type searchConfig struct { - postProcess searchPostProcessFn -} - -type searchOption func(*searchConfig) - -// withSearchPostProcess registers a callback invoked after a successful search -// response. The callback may mutate the call result (e.g. to attach _meta.ifc). -func withSearchPostProcess(fn searchPostProcessFn) searchOption { - return func(c *searchConfig) { c.postProcess = fn } -} - -func searchHandler( - ctx context.Context, - getClient GetClientFn, - args map[string]any, - searchType string, - errorPrefix string, - options ...searchOption, -) (*mcp.CallToolResult, error) { - cfg := searchConfig{} - for _, opt := range options { - opt(&cfg) - } +// prepareSearchArgs resolves the search query string and REST search options from the tool args, +// applying the standard is: / repo:/ query transformations 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) { @@ -77,12 +57,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) { @@ -91,25 +71,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) @@ -136,8 +128,5 @@ func searchHandler( } callResult := utils.NewToolResultText(string(r)) - if cfg.postProcess != nil { - cfg.postProcess(ctx, result, callResult) - } return callResult, nil }