From 655bfde94ad012dff203d690d3b78984287c1b29 Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:53:17 +0000 Subject: [PATCH] Add opt-in PR readiness fields to list tools --- README.md | 4 +- .../__toolsnaps__/list_pull_requests.snap | 6 +- .../__toolsnaps__/search_pull_requests.snap | 6 +- pkg/github/minimal_types.go | 14 +- pkg/github/pullrequests.go | 30 ++- pkg/github/pullrequests_readiness.go | 124 +++++++++++ pkg/github/pullrequests_readiness_test.go | 204 ++++++++++++++++++ pkg/github/search_utils.go | 15 +- 8 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 pkg/github/pullrequests_readiness.go create mode 100644 pkg/github/pullrequests_readiness_test.go diff --git a/README.md b/README.md index d013303d81..32ab357040 100644 --- a/README.md +++ b/README.md @@ -1252,7 +1252,7 @@ The following sets of tools are available: - **OAuth Challenge Scopes**: `repo` - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) - - `fields`: Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data. (string[], optional) + - `fields`: Subset of fields to return for each pull request. If omitted, all standard fields are returned. Select review_decision for the nullable review decision and status_check_rollup for the nullable aggregate check state of the PR's latest commit; these add one batch GraphQL request per page. Omitting 'body' drops the largest per-result data. (string[], optional) - `head`: Filter by head user/org and branch (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1305,7 +1305,7 @@ The following sets of tools are available: - **search_pull_requests** - Search pull requests - **OAuth Challenge Scopes**: `repo` - - `fields`: Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional) + - `fields`: Subset of fields to return for each pull request result. If omitted, all standard fields are returned. Select review_decision for the nullable review decision and status_check_rollup for the nullable aggregate check state of the PR's latest commit; these add one batch GraphQL request per page. Omitting 'body', 'reactions', and 'labels' drops the largest per-result data. (string[], optional) - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index d37986d529..0922d86f02 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -20,7 +20,7 @@ "type": "string" }, "fields": { - "description": "Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data.", + "description": "Subset of fields to return for each pull request. If omitted, all standard fields are returned. Select review_decision for the nullable review decision and status_check_rollup for the nullable aggregate check state of the PR's latest commit; these add one batch GraphQL request per page. Omitting 'body' drops the largest per-result data.", "items": { "enum": [ "number", @@ -47,7 +47,9 @@ "updated_at", "closed_at", "merged_at", - "milestone" + "milestone", + "review_decision", + "status_check_rollup" ], "type": "string" }, diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap index 847168b471..d8af58d076 100644 --- a/pkg/github/__toolsnaps__/search_pull_requests.snap +++ b/pkg/github/__toolsnaps__/search_pull_requests.snap @@ -8,7 +8,7 @@ "inputSchema": { "properties": { "fields": { - "description": "Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", + "description": "Subset of fields to return for each pull request result. If omitted, all standard fields are returned. Select review_decision for the nullable review decision and status_check_rollup for the nullable aggregate check state of the PR's latest commit; these add one batch GraphQL request per page. Omitting 'body', 'reactions', and 'labels' drops the largest per-result data.", "items": { "enum": [ "number", @@ -32,7 +32,9 @@ "closed_at", "closed_by", "pull_request", - "repository_url" + "repository_url", + "review_decision", + "status_check_rollup" ], "type": "string" }, diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 2eba9a1628..d0ef538b89 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -41,14 +41,15 @@ var listIssuesItemFieldEnum = []any{ } // listPullRequestsItemFieldEnum lists the selectable fields for -// list_pull_requests result items, matching the JSON field names of -// MinimalPullRequest. The body field is the heaviest, so omitting it is the main -// lever for shrinking large result sets. +// list_pull_requests result items. Most fields come from MinimalPullRequest; +// review_decision and status_check_rollup are opt-in GraphQL enrichments. The +// body field is the heaviest, so omitting it shrinks large result sets. var listPullRequestsItemFieldEnum = []any{ "number", "title", "body", "state", "draft", "merged", "mergeable_state", "html_url", "user", "labels", "assignees", "requested_reviewers", "merged_by", "head", "base", "additions", "deletions", "changed_files", "commits", "comments", "created_at", "updated_at", "closed_at", "merged_at", "milestone", + "review_decision", "status_check_rollup", } // listCommitsItemFieldEnum lists the selectable fields for list_commits result @@ -83,14 +84,15 @@ var searchIssuesItemFieldEnum = []any{ // searchPullRequestsItemFieldEnum lists the selectable fields for // search_pull_requests result items. Issue search returns pull requests as -// github.Issue objects, so this is a curated subset of those JSON field names. -// The body, reactions, and labels fields are the heaviest, so omitting them is -// the main lever for shrinking large result sets. +// github.Issue objects, so most fields are a curated subset of those JSON +// names. review_decision and status_check_rollup are opt-in GraphQL enrichments. +// Omitting body, reactions, and labels shrinks large result sets. var searchPullRequestsItemFieldEnum = []any{ "number", "title", "body", "state", "state_reason", "draft", "locked", "html_url", "user", "author_association", "labels", "assignee", "assignees", "milestone", "comments", "reactions", "created_at", "updated_at", "closed_at", "closed_by", "pull_request", "repository_url", + "review_decision", "status_check_rollup", } // filterFields marshals v to a JSON object and returns a map containing only the diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 5cee8b3231..811d82c0d6 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1364,7 +1364,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, } schema.Properties["fields"] = fieldsSchemaProperty( - "Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data.", + "Subset of fields to return for each pull request. If omitted, all standard fields are returned. Select review_decision for the nullable review decision and status_check_rollup for the nullable aggregate check state of the PR's latest commit; these add one batch GraphQL request per page. Omitting 'body' drops the largest per-result data.", listPullRequestsItemFieldEnum, ) WithPagination(schema) @@ -1454,9 +1454,11 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } minimalPRs := make([]MinimalPullRequest, 0, len(prs)) + nodeIDs := make([]string, 0, len(prs)) for _, pr := range prs { if pr != nil { minimalPRs = append(minimalPRs, convertToMinimalPullRequest(pr)) + nodeIDs = append(nodeIDs, pr.GetNodeID()) } } @@ -1467,6 +1469,15 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultErrorFromErr("failed to filter pull requests", err), nil, nil } + if requestedPullRequestReadiness(fields) { + readiness, err := fetchPullRequestReadiness(ctx, deps, nodeIDs, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to fetch pull request readiness", err), nil, nil + } + if err := addPullRequestReadinessFields(filteredPRs, nodeIDs, fields, readiness); err != nil { + return utils.NewToolResultErrorFromErr("failed to enrich pull request readiness", err), nil, nil + } + } payload = filteredPRs filtered = true } @@ -1647,7 +1658,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"query"}, } schema.Properties["fields"] = fieldsSchemaProperty( - "Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", + "Subset of fields to return for each pull request result. If omitted, all standard fields are returned. Select review_decision for the nullable review decision and status_check_rollup for the nullable aggregate check state of the PR's latest commit; these add one batch GraphQL request per page. Omitting 'body', 'reactions', and 'labels' drops the largest per-result data.", searchPullRequestsItemFieldEnum, ) WithPagination(schema) @@ -1671,6 +1682,21 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo return utils.NewToolResultError(err.Error()), nil, nil } options = append(options, withFieldsFiltering(deps, "search_pull_requests", fields)) + if requestedPullRequestReadiness(fields) { + options = append(options, withSearchFieldsEnrichment(func(ctx context.Context, issues []*github.Issue, items []map[string]any) error { + nodeIDs := make([]string, len(issues)) + for i, issue := range issues { + if issue != nil { + nodeIDs[i] = issue.GetNodeID() + } + } + readiness, err := fetchPullRequestReadiness(ctx, deps, nodeIDs, fields) + if err != nil { + return err + } + return addPullRequestReadinessFields(items, nodeIDs, fields, readiness) + })) + } result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", options...) return result, nil, err }) diff --git a/pkg/github/pullrequests_readiness.go b/pkg/github/pullrequests_readiness.go new file mode 100644 index 0000000000..e71bb3e2c7 --- /dev/null +++ b/pkg/github/pullrequests_readiness.go @@ -0,0 +1,124 @@ +package github + +import ( + "context" + "fmt" + + "github.com/shurcooL/githubv4" +) + +// Pull request readiness is queried only for explicitly selected fields. REST +// list/search responses carry node IDs, so one nodes query enriches the whole +// page, including search pages spanning several repositories. +type pullRequestReadinessQuery struct { + Nodes []struct { + PullRequest struct { + ID githubv4.ID + ReviewDecision githubv4.String `graphql:"reviewDecision @include(if: $includeReview)"` + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup struct { + State githubv4.String + } + } + } + } `graphql:"commits(last: 1) @include(if: $includeChecks)"` + } `graphql:"... on PullRequest"` + } `graphql:"nodes(ids: $ids)"` +} + +type pullRequestReadiness struct { + ReviewDecision *string + StatusCheckRollup *string +} + +func requestedPullRequestReadiness(fields []string) bool { + for _, field := range fields { + if field == "review_decision" || field == "status_check_rollup" { + return true + } + } + return false +} + +func fetchPullRequestReadiness(ctx context.Context, deps ToolDependencies, nodeIDs []string, fields []string) (map[string]pullRequestReadiness, error) { + if len(nodeIDs) == 0 { + return nil, nil + } + ids := make([]githubv4.ID, len(nodeIDs)) + for i, id := range nodeIDs { + if id == "" { + return nil, fmt.Errorf("pull request readiness requires a node ID for item %d", i+1) + } + ids[i] = githubv4.ID(id) + } + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("get GraphQL client for pull request readiness: %w", err) + } + if client == nil { + return nil, fmt.Errorf("GraphQL client for pull request readiness is unavailable") + } + var includeReview, includeChecks bool + for _, field := range fields { + switch field { + case "review_decision": + includeReview = true + case "status_check_rollup": + includeChecks = true + } + } + var q pullRequestReadinessQuery + if err := client.Query(ctx, &q, map[string]any{ + "ids": ids, + "includeReview": githubv4.Boolean(includeReview), + "includeChecks": githubv4.Boolean(includeChecks), + }); err != nil { + return nil, fmt.Errorf("query pull request readiness: %w", err) + } + result := make(map[string]pullRequestReadiness, len(q.Nodes)) + for _, node := range q.Nodes { + id, ok := node.PullRequest.ID.(string) + if !ok || id == "" { + return nil, fmt.Errorf("pull request readiness response contained a missing or non-PR node") + } + var item pullRequestReadiness + if value := string(node.PullRequest.ReviewDecision); value != "" { + item.ReviewDecision = &value + } + if commits := node.PullRequest.Commits.Nodes; len(commits) > 0 { + if value := string(commits[0].Commit.StatusCheckRollup.State); value != "" { + item.StatusCheckRollup = &value + } + } + result[id] = item + } + for _, id := range nodeIDs { + if _, ok := result[id]; !ok { + return nil, fmt.Errorf("pull request readiness response omitted node %s", id) + } + } + return result, nil +} + +func addPullRequestReadinessFields(items []map[string]any, nodeIDs []string, fields []string, readiness map[string]pullRequestReadiness) error { + if len(items) != len(nodeIDs) { + return fmt.Errorf("pull request readiness item count mismatch") + } + for i, id := range nodeIDs { + value, ok := readiness[id] + if !ok { + return fmt.Errorf("pull request readiness missing for node %s", id) + } + for _, field := range fields { + switch field { + case "review_decision": + items[i][field] = value.ReviewDecision + case "status_check_rollup": + items[i][field] = value.StatusCheckRollup + } + } + } + return nil +} diff --git a/pkg/github/pullrequests_readiness_test.go b/pkg/github/pullrequests_readiness_test.go new file mode 100644 index 0000000000..1a275e6ce6 --- /dev/null +++ b/pkg/github/pullrequests_readiness_test.go @@ -0,0 +1,204 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullRequestReadinessFields(t *testing.T) { + cases := []struct { + name, tool string + fields []string + response string + graphql bool + wantError string + }{ + {"list batch", "list", []string{"number", "review_decision", "status_check_rollup"}, `{"data":{"nodes":[{"id":"PR_one","reviewDecision":"APPROVED","commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"SUCCESS"}}}]}},{"id":"PR_two","reviewDecision":null,"commits":{"nodes":[{"commit":{"statusCheckRollup":null}}]}}]}}`, true, ""}, + {"search batch across repos", "search", []string{"number", "review_decision", "status_check_rollup"}, `{"data":{"nodes":[{"id":"PR_one","reviewDecision":"CHANGES_REQUESTED","commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]}},{"id":"PR_two","reviewDecision":"REVIEW_REQUIRED","commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"PENDING"}}}]}}]}}`, true, ""}, + {"list default", "list", nil, "", false, ""}, + {"search other fields", "search", []string{"number"}, "", false, ""}, + {"list GraphQL error", "list", []string{"review_decision"}, `{"errors":[{"message":"readiness unavailable"}]}`, true, "readiness"}, + {"search missing node", "search", []string{"status_check_rollup"}, `{"data":{"nodes":[{"id":"PR_one","reviewDecision":null,"commits":{"nodes":[{"commit":{"statusCheckRollup":null}}]}},null]}}`, true, "readiness"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gql := &sequencedGraphQLTransport{t: t} + if tc.graphql { + gql.responses = []func(capturedGraphQLRequest) (int, string){func(req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "nodes(ids:") + assert.Contains(t, req.Query, "commits(last: 1)") + assert.NotContains(t, req.Query, "headRef") + assert.Equal(t, []any{"PR_one", "PR_two"}, req.Variables["ids"]) + return http.StatusOK, tc.response + }} + } + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{Transport: gql})} + args := map[string]any{"page": float64(2), "perPage": float64(2)} + if tc.fields != nil { + args["fields"] = tc.fields + } + var content string + var isError bool + if tc.tool == "list" { + prs := []*github.PullRequest{ + {NodeID: github.Ptr("PR_one"), Number: github.Ptr(1), Title: github.Ptr("First"), State: github.Ptr("open")}, + {NodeID: github.Ptr("PR_two"), Number: github.Ptr(2), Title: github.Ptr("Second"), State: github.Ptr("open")}, + } + deps.Client = mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: expectQueryParams(t, map[string]string{"page": "2", "per_page": "2"}).andThen(mockResponse(t, http.StatusOK, prs)), + })) + args["owner"] = "owner" + args["repo"] = "repo" + req := createMCPRequest(args) + tool := ListPullRequests(translations.NullTranslationHelper) + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + isError = result.IsError + content = getTextResult(t, result).Text + } else { + issues := &github.IssuesSearchResult{Total: github.Ptr(2), IncompleteResults: github.Ptr(false), Issues: []*github.Issue{ + {NodeID: github.Ptr("PR_one"), Number: github.Ptr(1), Title: github.Ptr("First"), State: github.Ptr("open"), RepositoryURL: github.Ptr("https://api.github.com/repos/a/one")}, + {NodeID: github.Ptr("PR_two"), Number: github.Ptr(2), Title: github.Ptr("Second"), State: github.Ptr("open"), RepositoryURL: github.Ptr("https://api.github.com/repos/b/two")}, + }} + deps.Client = mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams(t, map[string]string{"q": "is:pr review", "page": "2", "per_page": "2"}).andThen(mockResponse(t, http.StatusOK, issues)), + })) + args["query"] = "review" + req := createMCPRequest(args) + tool := SearchPullRequests(translations.NullTranslationHelper) + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + isError = result.IsError + content = getTextResult(t, result).Text + } + if tc.graphql { + assert.Len(t, gql.calls, 1) + } else { + assert.Empty(t, gql.calls) + } + if tc.wantError != "" { + require.True(t, isError) + assert.Contains(t, strings.ToLower(content), tc.wantError) + return + } + require.False(t, isError) + var payload any + require.NoError(t, json.Unmarshal([]byte(content), &payload)) + var items []any + if tc.tool == "list" { + items = payload.([]any) + } else { + items = payload.(map[string]any)["items"].([]any) + } + require.Len(t, items, 2) + if !tc.graphql { + return + } + first := items[0].(map[string]any) + second := items[1].(map[string]any) + assert.Equal(t, float64(1), first["number"]) + assert.Equal(t, float64(2), second["number"]) + if tc.tool == "list" { + assert.Equal(t, "APPROVED", first["review_decision"]) + assert.Equal(t, "SUCCESS", first["status_check_rollup"]) + assert.Nil(t, second["review_decision"]) + assert.Nil(t, second["status_check_rollup"]) + } else { + assert.Equal(t, "CHANGES_REQUESTED", first["review_decision"]) + assert.Equal(t, "FAILURE", first["status_check_rollup"]) + assert.Equal(t, "REVIEW_REQUIRED", second["review_decision"]) + assert.Equal(t, "PENDING", second["status_check_rollup"]) + } + }) + } +} + +func TestPullRequestReadinessFieldSchema(t *testing.T) { + for _, tool := range []struct { + name string + schema *jsonschema.Schema + }{ + {"list_pull_requests", ListPullRequests(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema)}, + {"search_pull_requests", SearchPullRequests(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema)}, + } { + field := tool.schema.Properties["fields"] + require.NotNil(t, field) + require.NotNil(t, field.Items) + assert.Contains(t, field.Items.Enum, "review_decision", tool.name) + assert.Contains(t, field.Items.Enum, "status_check_rollup", tool.name) + } +} + +func TestFetchPullRequestReadinessSelectedFields(t *testing.T) { + cases := []struct { + name string + fields []string + response string + review any + checks any + includeReview bool + includeChecks bool + }{ + { + name: "review only", + fields: []string{"review_decision"}, + response: `{"data":{"nodes":[{"id":"PR_one","reviewDecision":"APPROVED"}]}}`, + review: "APPROVED", + includeReview: true, + }, + { + name: "checks only from latest commit", + fields: []string{"status_check_rollup"}, + response: `{"data":{"nodes":[{"id":"PR_one","commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"SUCCESS"}}}]}}]}}`, + checks: "SUCCESS", + includeChecks: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + transport := &sequencedGraphQLTransport{t: t, responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Equal(t, tc.includeReview, req.Variables["includeReview"]) + assert.Equal(t, tc.includeChecks, req.Variables["includeChecks"]) + assert.Contains(t, req.Query, "@include(if: $includeReview)") + assert.Contains(t, req.Query, "@include(if: $includeChecks)") + return http.StatusOK, tc.response + }, + }} + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{Transport: transport})} + values, err := fetchPullRequestReadiness(context.Background(), deps, []string{"PR_one"}, tc.fields) + require.NoError(t, err) + require.Len(t, transport.calls, 1) + require.Contains(t, values, "PR_one") + if tc.review == nil { + assert.Nil(t, values["PR_one"].ReviewDecision) + } else { + assert.Equal(t, tc.review, *values["PR_one"].ReviewDecision) + } + if tc.checks == nil { + assert.Nil(t, values["PR_one"].StatusCheckRollup) + } else { + assert.Equal(t, tc.checks, *values["PR_one"].StatusCheckRollup) + } + }) + } +} + +func TestFetchPullRequestReadinessNoResultsAndMissingID(t *testing.T) { + deps := BaseDeps{} + values, err := fetchPullRequestReadiness(context.Background(), deps, nil, []string{"review_decision"}) + require.NoError(t, err) + assert.Empty(t, values) + _, err = fetchPullRequestReadiness(context.Background(), deps, []string{""}, []string{"review_decision"}) + require.ErrorContains(t, err, "node ID") +} diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 52d735ca0b..8b9d33c10f 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -43,8 +43,11 @@ 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 searchFieldsEnrichmentFn func(ctx context.Context, issues []*github.Issue, items []map[string]any) error + type searchConfig struct { - postProcess searchPostProcessFn + postProcess searchPostProcessFn + enrichFields searchFieldsEnrichmentFn // fields, when non-empty, restricts each result item to the requested // subset of fields. fieldsTool and fieldsDeps identify the calling tool and // its dependencies so fields telemetry can be recorded. @@ -61,6 +64,11 @@ func withSearchPostProcess(fn searchPostProcessFn) searchOption { return func(c *searchConfig) { c.postProcess = fn } } +// withSearchFieldsEnrichment adds optional fields to a filtered search page. +func withSearchFieldsEnrichment(fn searchFieldsEnrichmentFn) searchOption { + return func(c *searchConfig) { c.enrichFields = fn } +} + // withFieldsFiltering enables the optional `fields` response filtering for a // search tool. When fields is non-empty, each result item is reduced to the // requested subset while the total_count / incomplete_results wrapper is @@ -217,6 +225,11 @@ func searchHandler( if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil } + if cfg.enrichFields != nil { + if err := cfg.enrichFields(ctx, result.Issues, filteredItems); err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to enrich results", err), nil + } + } payload = map[string]any{ "total_count": result.Total, "incomplete_results": result.IncompleteResults,