Skip to content

Commit 558be5c

Browse files
committed
feat: reduce pull request search responses by default
1 parent 3422703 commit 558be5c

5 files changed

Lines changed: 240 additions & 29 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,7 @@ The following sets of tools are available:
11471147

11481148
- **search_pull_requests** - Search pull requests
11491149
- **Required OAuth Scopes**: `repo`
1150+
- `minimal_output`: Return minimal pull request search results (default: true). When false, returns the full GitHub API search payload. (boolean, optional)
11501151
- `order`: Sort order (string, optional)
11511152
- `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional)
11521153
- `page`: Page number for pagination (min 1) (number, optional)

pkg/github/__toolsnaps__/search_pull_requests.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
"description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr",
77
"inputSchema": {
88
"properties": {
9+
"minimal_output": {
10+
"default": true,
11+
"description": "Return minimal pull request search results (default: true). When false, returns the full GitHub API search payload.",
12+
"type": "boolean"
13+
},
914
"order": {
1015
"description": "Sort order",
1116
"enum": [

pkg/github/minimal_types.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,38 @@ type MinimalSearchCommitsResult struct {
370370
Items []MinimalCommitSearchItem `json:"items"`
371371
}
372372

373+
// MinimalSearchPullRequestsResult is the trimmed output type for pull request search results.
374+
type MinimalSearchPullRequestsResult struct {
375+
TotalCount int `json:"total_count"`
376+
IncompleteResults bool `json:"incomplete_results"`
377+
Items []MinimalSearchPullRequestItem `json:"items"`
378+
}
379+
380+
// MinimalSearchPullRequestItem is the trimmed output type for a single pull request search hit.
381+
type MinimalSearchPullRequestItem struct {
382+
Number int `json:"number"`
383+
Title string `json:"title"`
384+
State string `json:"state"`
385+
Draft bool `json:"draft,omitempty"`
386+
User string `json:"user,omitempty"`
387+
CreatedAt string `json:"created_at,omitempty"`
388+
UpdatedAt string `json:"updated_at,omitempty"`
389+
HTMLURL string `json:"html_url,omitempty"`
390+
Repository string `json:"repository,omitempty"`
391+
RepositoryURL string `json:"repository_url,omitempty"`
392+
Labels []string `json:"labels,omitempty"`
393+
Comments int `json:"comments,omitempty"`
394+
PullRequest *MinimalSearchPullRequestLinks `json:"pull_request,omitempty"`
395+
}
396+
397+
// MinimalSearchPullRequestLinks contains the PR-specific URLs already present on an issue search hit.
398+
type MinimalSearchPullRequestLinks struct {
399+
URL string `json:"url,omitempty"`
400+
HTMLURL string `json:"html_url,omitempty"`
401+
DiffURL string `json:"diff_url,omitempty"`
402+
PatchURL string `json:"patch_url,omitempty"`
403+
}
404+
373405
// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
374406
type MinimalFileContentResponse struct {
375407
Content *MinimalFileContent `json:"content,omitempty"`
@@ -1565,6 +1597,58 @@ func convertCommitResultToMinimalCommit(commit *github.CommitResult) MinimalComm
15651597
return item
15661598
}
15671599

1600+
func convertToMinimalSearchPullRequestsResult(result *github.IssuesSearchResult) MinimalSearchPullRequestsResult {
1601+
minimal := MinimalSearchPullRequestsResult{}
1602+
if result == nil {
1603+
return minimal
1604+
}
1605+
1606+
minimal.TotalCount = result.GetTotal()
1607+
minimal.IncompleteResults = result.GetIncompleteResults()
1608+
minimal.Items = make([]MinimalSearchPullRequestItem, 0, len(result.Issues))
1609+
for _, issue := range result.Issues {
1610+
if issue == nil {
1611+
continue
1612+
}
1613+
minimal.Items = append(minimal.Items, convertToMinimalSearchPullRequestItem(issue))
1614+
}
1615+
1616+
return minimal
1617+
}
1618+
1619+
func convertToMinimalSearchPullRequestItem(issue *github.Issue) MinimalSearchPullRequestItem {
1620+
minimal := MinimalSearchPullRequestItem{
1621+
Number: issue.GetNumber(),
1622+
Title: issue.GetTitle(),
1623+
State: issue.GetState(),
1624+
Draft: issue.GetDraft(),
1625+
User: issue.GetUser().GetLogin(),
1626+
CreatedAt: formatProjectTimestamp(issue.CreatedAt),
1627+
UpdatedAt: formatProjectTimestamp(issue.UpdatedAt),
1628+
HTMLURL: issue.GetHTMLURL(),
1629+
Repository: issueRepositoryFullName(issue),
1630+
RepositoryURL: issue.GetRepositoryURL(),
1631+
Comments: issue.GetComments(),
1632+
}
1633+
1634+
for _, label := range issue.Labels {
1635+
if label != nil {
1636+
minimal.Labels = append(minimal.Labels, label.GetName())
1637+
}
1638+
}
1639+
1640+
if links := issue.GetPullRequestLinks(); links != nil {
1641+
minimal.PullRequest = &MinimalSearchPullRequestLinks{
1642+
URL: links.GetURL(),
1643+
HTMLURL: links.GetHTMLURL(),
1644+
DiffURL: links.GetDiffURL(),
1645+
PatchURL: links.GetPatchURL(),
1646+
}
1647+
}
1648+
1649+
return minimal
1650+
}
1651+
15681652
// MinimalPageInfo contains pagination cursor information.
15691653
type MinimalPageInfo struct {
15701654
HasNextPage bool `json:"hasNextPage"`

pkg/github/pullrequests.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,11 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo
14751475
Description: "Sort order",
14761476
Enum: []any{"asc", "desc"},
14771477
},
1478+
"minimal_output": {
1479+
Type: "boolean",
1480+
Description: "Return minimal pull request search results (default: true). When false, returns the full GitHub API search payload.",
1481+
Default: json.RawMessage(`true`),
1482+
},
14781483
},
14791484
Required: []string{"query"},
14801485
}
@@ -1493,8 +1498,50 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo
14931498
},
14941499
[]scopes.Scope{scopes.Repo},
14951500
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1496-
result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", ifcSearchPostProcessOption(ctx, deps))
1497-
return result, nil, err
1501+
minimalOutput, err := OptionalBoolParamWithDefault(args, "minimal_output", true)
1502+
if err != nil {
1503+
return utils.NewToolResultError(err.Error()), nil, nil
1504+
}
1505+
if !minimalOutput {
1506+
result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", ifcSearchPostProcessOption(ctx, deps))
1507+
return result, nil, err
1508+
}
1509+
1510+
query, opts, err := prepareSearchArgs(args, "pr")
1511+
if err != nil {
1512+
return utils.NewToolResultError(err.Error()), nil, nil
1513+
}
1514+
1515+
client, err := deps.GetClient(ctx)
1516+
if err != nil {
1517+
return utils.NewToolResultErrorFromErr("failed to search pull requests: failed to get GitHub client", err), nil, nil
1518+
}
1519+
result, resp, err := client.Search.Issues(ctx, query, opts)
1520+
if err != nil {
1521+
return utils.NewToolResultErrorFromErr("failed to search pull requests", err), nil, nil
1522+
}
1523+
defer func() { _ = resp.Body.Close() }()
1524+
1525+
if resp.StatusCode != http.StatusOK {
1526+
body, err := io.ReadAll(resp.Body)
1527+
if err != nil {
1528+
return utils.NewToolResultErrorFromErr("failed to search pull requests: failed to read response body", err), nil, nil
1529+
}
1530+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search pull requests", resp, body), nil, nil
1531+
}
1532+
1533+
r, err := json.Marshal(convertToMinimalSearchPullRequestsResult(result))
1534+
if err != nil {
1535+
return utils.NewToolResultErrorFromErr("failed to search pull requests: failed to marshal response", err), nil, nil
1536+
}
1537+
1538+
callResult := utils.NewToolResultText(string(r))
1539+
cfg := searchConfig{}
1540+
ifcSearchPostProcessOption(ctx, deps)(&cfg)
1541+
if cfg.postProcess != nil {
1542+
cfg.postProcess(ctx, result, callResult)
1543+
}
1544+
return callResult, nil, nil
14981545
})
14991546
}
15001547

pkg/github/pullrequests_test.go

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -829,23 +829,45 @@ func Test_SearchPullRequests(t *testing.T) {
829829
assert.Contains(t, schema.Properties, "repo")
830830
assert.Contains(t, schema.Properties, "sort")
831831
assert.Contains(t, schema.Properties, "order")
832+
assert.Contains(t, schema.Properties, "minimal_output")
832833
assert.Contains(t, schema.Properties, "perPage")
833834
assert.Contains(t, schema.Properties, "page")
834835
assert.ElementsMatch(t, schema.Required, []string{"query"})
835836

837+
createdAt := &github.Timestamp{Time: time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)}
838+
updatedAt := &github.Timestamp{Time: time.Date(2026, 6, 11, 15, 30, 0, 0, time.UTC)}
836839
mockSearchResult := &github.IssuesSearchResult{
837840
Total: github.Ptr(2),
838841
IncompleteResults: github.Ptr(false),
839842
Issues: []*github.Issue{
840843
{
841-
Number: github.Ptr(42),
842-
Title: github.Ptr("Test PR 1"),
843-
Body: github.Ptr("Updated tests."),
844-
State: github.Ptr("open"),
845-
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"),
846-
Comments: github.Ptr(5),
844+
Number: github.Ptr(42),
845+
Title: github.Ptr("Test PR 1"),
846+
Body: github.Ptr("Updated tests."),
847+
State: github.Ptr("open"),
848+
Draft: github.Ptr(true),
849+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"),
850+
RepositoryURL: github.Ptr("https://api.github.com/repos/owner/repo"),
851+
Comments: github.Ptr(5),
852+
CreatedAt: createdAt,
853+
UpdatedAt: updatedAt,
847854
User: &github.User{
848-
Login: github.Ptr("user1"),
855+
Login: github.Ptr("user1"),
856+
URL: github.Ptr("https://api.github.com/users/user1"),
857+
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1?v=4"),
858+
},
859+
Repository: &github.Repository{
860+
FullName: github.Ptr("owner/repo"),
861+
},
862+
Labels: []*github.Label{
863+
{Name: github.Ptr("ci")},
864+
{Name: github.Ptr("backend")},
865+
},
866+
PullRequestLinks: &github.PullRequestLinks{
867+
URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/1"),
868+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"),
869+
DiffURL: github.Ptr("https://github.com/owner/repo/pull/1.diff"),
870+
PatchURL: github.Ptr("https://github.com/owner/repo/pull/1.patch"),
849871
},
850872
},
851873
{
@@ -863,12 +885,14 @@ func Test_SearchPullRequests(t *testing.T) {
863885
}
864886

865887
tests := []struct {
866-
name string
867-
mockedClient *http.Client
868-
requestArgs map[string]any
869-
expectError bool
870-
expectedResult *github.IssuesSearchResult
871-
expectedErrMsg string
888+
name string
889+
mockedClient *http.Client
890+
requestArgs map[string]any
891+
expectError bool
892+
expectRaw bool
893+
expectedResult *github.IssuesSearchResult
894+
expectedErrMsg string
895+
expectedJSONLack []string
872896
}{
873897
{
874898
name: "successful pull request search with all parameters",
@@ -972,8 +996,9 @@ func Test_SearchPullRequests(t *testing.T) {
972996
requestArgs: map[string]any{
973997
"query": "is:pr repo:owner/repo is:open",
974998
},
975-
expectError: false,
976-
expectedResult: mockSearchResult,
999+
expectError: false,
1000+
expectedResult: mockSearchResult,
1001+
expectedJSONLack: []string{"avatar_url", "\"url\":\"https://api.github.com/users/user1\""},
9771002
},
9781003
{
9791004
name: "query with existing is:pr filter - no duplication",
@@ -1051,6 +1076,18 @@ func Test_SearchPullRequests(t *testing.T) {
10511076
expectError: true,
10521077
expectedErrMsg: "failed to search pull requests",
10531078
},
1079+
{
1080+
name: "pull request search with minimal_output false returns raw result",
1081+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1082+
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
1083+
}),
1084+
requestArgs: map[string]any{
1085+
"query": "is:pr repo:owner/repo is:open",
1086+
"minimal_output": false,
1087+
},
1088+
expectRaw: true,
1089+
expectedResult: mockSearchResult,
1090+
},
10541091
}
10551092

10561093
for _, tc := range tests {
@@ -1084,20 +1121,57 @@ func Test_SearchPullRequests(t *testing.T) {
10841121
// Parse the result and get the text content if no error
10851122
textContent := getTextResult(t, result)
10861123

1087-
// Unmarshal and verify the result
1088-
var returnedResult github.IssuesSearchResult
1124+
if tc.expectRaw {
1125+
var returnedResult github.IssuesSearchResult
1126+
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
1127+
require.NoError(t, err)
1128+
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
1129+
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
1130+
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
1131+
for i, issue := range returnedResult.Issues {
1132+
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
1133+
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
1134+
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
1135+
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
1136+
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
1137+
}
1138+
assert.Contains(t, textContent.Text, "\"avatar_url\":\"https://avatars.githubusercontent.com/u/1?v=4\"")
1139+
return
1140+
}
1141+
1142+
for _, missingJSON := range tc.expectedJSONLack {
1143+
assert.NotContains(t, textContent.Text, missingJSON)
1144+
}
1145+
1146+
var returnedResult MinimalSearchPullRequestsResult
10891147
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
10901148
require.NoError(t, err)
1091-
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
1092-
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
1093-
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
1094-
for i, issue := range returnedResult.Issues {
1095-
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
1096-
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
1097-
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
1098-
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
1099-
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
1100-
}
1149+
assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount)
1150+
assert.Equal(t, tc.expectedResult.GetIncompleteResults(), returnedResult.IncompleteResults)
1151+
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Issues))
1152+
1153+
first := returnedResult.Items[0]
1154+
assert.Equal(t, 42, first.Number)
1155+
assert.Equal(t, "Test PR 1", first.Title)
1156+
assert.Equal(t, "open", first.State)
1157+
assert.True(t, first.Draft)
1158+
assert.Equal(t, "user1", first.User)
1159+
assert.Equal(t, createdAt.Format(time.RFC3339), first.CreatedAt)
1160+
assert.Equal(t, updatedAt.Format(time.RFC3339), first.UpdatedAt)
1161+
assert.Equal(t, "https://github.com/owner/repo/pull/1", first.HTMLURL)
1162+
assert.Equal(t, "owner/repo", first.Repository)
1163+
assert.Equal(t, "https://api.github.com/repos/owner/repo", first.RepositoryURL)
1164+
assert.Equal(t, []string{"ci", "backend"}, first.Labels)
1165+
assert.Equal(t, 5, first.Comments)
1166+
require.NotNil(t, first.PullRequest)
1167+
assert.Equal(t, "https://api.github.com/repos/owner/repo/pulls/1", first.PullRequest.URL)
1168+
assert.Equal(t, "https://github.com/owner/repo/pull/1", first.PullRequest.HTMLURL)
1169+
assert.Equal(t, "https://github.com/owner/repo/pull/1.diff", first.PullRequest.DiffURL)
1170+
assert.Equal(t, "https://github.com/owner/repo/pull/1.patch", first.PullRequest.PatchURL)
1171+
1172+
second := returnedResult.Items[1]
1173+
assert.Equal(t, 43, second.Number)
1174+
assert.Equal(t, "user2", second.User)
11011175
})
11021176
}
11031177

0 commit comments

Comments
 (0)