Skip to content

Commit 5f92434

Browse files
JoannaaKLSamMorrowDrums
authored andcommitted
Add search pull requests tool
1 parent acba284 commit 5f92434

File tree

4 files changed

+246
-1
lines changed

4 files changed

+246
-1
lines changed

pkg/github/issues.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
153153
}
154154
}
155155

156-
// SearchIssues creates a tool to search for issues and pull requests.
156+
// SearchIssues creates a tool to search for issues.
157157
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
158158
return mcp.NewTool("search_issues",
159159
mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories.")),

pkg/github/pullrequests.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,94 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
533533
}
534534
}
535535

536+
// SearchPullRequests creates a tool to search for pull requests.
537+
func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
538+
return mcp.NewTool("search_pull_requests",
539+
mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories.")),
540+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
541+
Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"),
542+
ReadOnlyHint: ToBoolPtr(true),
543+
}),
544+
mcp.WithString("q",
545+
mcp.Required(),
546+
mcp.Description("Search query using GitHub pull request search syntax"),
547+
),
548+
mcp.WithString("sort",
549+
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
550+
mcp.Enum(
551+
"comments",
552+
"reactions",
553+
"reactions-+1",
554+
"reactions--1",
555+
"reactions-smile",
556+
"reactions-thinking_face",
557+
"reactions-heart",
558+
"reactions-tada",
559+
"interactions",
560+
"created",
561+
"updated",
562+
),
563+
),
564+
mcp.WithString("order",
565+
mcp.Description("Sort order"),
566+
mcp.Enum("asc", "desc"),
567+
),
568+
WithPagination(),
569+
),
570+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
571+
query, err := RequiredParam[string](request, "q")
572+
if err != nil {
573+
return mcp.NewToolResultError(err.Error()), nil
574+
}
575+
sort, err := OptionalParam[string](request, "sort")
576+
if err != nil {
577+
return mcp.NewToolResultError(err.Error()), nil
578+
}
579+
order, err := OptionalParam[string](request, "order")
580+
if err != nil {
581+
return mcp.NewToolResultError(err.Error()), nil
582+
}
583+
pagination, err := OptionalPaginationParams(request)
584+
if err != nil {
585+
return mcp.NewToolResultError(err.Error()), nil
586+
}
587+
588+
opts := &github.SearchOptions{
589+
Sort: sort,
590+
Order: order,
591+
ListOptions: github.ListOptions{
592+
PerPage: pagination.perPage,
593+
Page: pagination.page,
594+
},
595+
}
596+
597+
client, err := getClient(ctx)
598+
if err != nil {
599+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
600+
}
601+
result, resp, err := client.Search.Issues(ctx, query, opts)
602+
if err != nil {
603+
return nil, fmt.Errorf("failed to search pull requests: %w", err)
604+
}
605+
defer func() { _ = resp.Body.Close() }()
606+
607+
if resp.StatusCode != http.StatusOK {
608+
body, err := io.ReadAll(resp.Body)
609+
if err != nil {
610+
return nil, fmt.Errorf("failed to read response body: %w", err)
611+
}
612+
return mcp.NewToolResultError(fmt.Sprintf("failed to search pull requests: %s", string(body))), nil
613+
}
614+
615+
r, err := json.Marshal(result)
616+
if err != nil {
617+
return nil, fmt.Errorf("failed to marshal response: %w", err)
618+
}
619+
620+
return mcp.NewToolResultText(string(r)), nil
621+
}
622+
}
623+
536624
// GetPullRequestFiles creates a tool to get the list of files changed in a pull request.
537625
func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
538626
return mcp.NewTool("get_pull_request_files",

pkg/github/pullrequests_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,162 @@ func Test_MergePullRequest(t *testing.T) {
565565
}
566566
}
567567

568+
func Test_SearchPullRequests(t *testing.T) {
569+
mockClient := github.NewClient(nil)
570+
tool, _ := SearchPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper)
571+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
572+
573+
assert.Equal(t, "search_pull_requests", tool.Name)
574+
assert.NotEmpty(t, tool.Description)
575+
assert.Contains(t, tool.InputSchema.Properties, "q")
576+
assert.Contains(t, tool.InputSchema.Properties, "sort")
577+
assert.Contains(t, tool.InputSchema.Properties, "order")
578+
assert.Contains(t, tool.InputSchema.Properties, "perPage")
579+
assert.Contains(t, tool.InputSchema.Properties, "page")
580+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
581+
582+
mockSearchResult := &github.IssuesSearchResult{
583+
Total: github.Ptr(2),
584+
IncompleteResults: github.Ptr(false),
585+
Issues: []*github.Issue{
586+
{
587+
Number: github.Ptr(42),
588+
Title: github.Ptr("Test PR 1"),
589+
Body: github.Ptr("Updated tests."),
590+
State: github.Ptr("open"),
591+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"),
592+
Comments: github.Ptr(5),
593+
User: &github.User{
594+
Login: github.Ptr("user1"),
595+
},
596+
},
597+
{
598+
Number: github.Ptr(43),
599+
Title: github.Ptr("Test PR 2"),
600+
Body: github.Ptr("Updated build scripts."),
601+
State: github.Ptr("open"),
602+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"),
603+
Comments: github.Ptr(3),
604+
User: &github.User{
605+
Login: github.Ptr("user2"),
606+
},
607+
},
608+
},
609+
}
610+
611+
tests := []struct {
612+
name string
613+
mockedClient *http.Client
614+
requestArgs map[string]interface{}
615+
expectError bool
616+
expectedResult *github.IssuesSearchResult
617+
expectedErrMsg string
618+
}{
619+
{
620+
name: "successful pull request search with all parameters",
621+
mockedClient: mock.NewMockedHTTPClient(
622+
mock.WithRequestMatchHandler(
623+
mock.GetSearchIssues,
624+
expectQueryParams(
625+
t,
626+
map[string]string{
627+
"q": "repo:owner/repo is:pr is:open",
628+
"sort": "created",
629+
"order": "desc",
630+
"page": "1",
631+
"per_page": "30",
632+
},
633+
).andThen(
634+
mockResponse(t, http.StatusOK, mockSearchResult),
635+
),
636+
),
637+
),
638+
requestArgs: map[string]interface{}{
639+
"q": "repo:owner/repo is:pr is:open",
640+
"sort": "created",
641+
"order": "desc",
642+
"page": float64(1),
643+
"perPage": float64(30),
644+
},
645+
expectError: false,
646+
expectedResult: mockSearchResult,
647+
},
648+
{
649+
name: "pull request search with minimal parameters",
650+
mockedClient: mock.NewMockedHTTPClient(
651+
mock.WithRequestMatch(
652+
mock.GetSearchIssues,
653+
mockSearchResult,
654+
),
655+
),
656+
requestArgs: map[string]interface{}{
657+
"q": "repo:owner/repo is:pr is:open",
658+
},
659+
expectError: false,
660+
expectedResult: mockSearchResult,
661+
},
662+
{
663+
name: "search pull requests fails",
664+
mockedClient: mock.NewMockedHTTPClient(
665+
mock.WithRequestMatchHandler(
666+
mock.GetSearchIssues,
667+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
668+
w.WriteHeader(http.StatusBadRequest)
669+
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
670+
}),
671+
),
672+
),
673+
requestArgs: map[string]interface{}{
674+
"q": "invalid:query",
675+
},
676+
expectError: true,
677+
expectedErrMsg: "failed to search issues",
678+
},
679+
}
680+
681+
for _, tc := range tests {
682+
t.Run(tc.name, func(t *testing.T) {
683+
// Setup client with mock
684+
client := github.NewClient(tc.mockedClient)
685+
_, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper)
686+
687+
// Create call request
688+
request := createMCPRequest(tc.requestArgs)
689+
690+
// Call handler
691+
result, err := handler(context.Background(), request)
692+
693+
// Verify results
694+
if tc.expectError {
695+
require.Error(t, err)
696+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
697+
return
698+
}
699+
700+
require.NoError(t, err)
701+
702+
// Parse the result and get the text content if no error
703+
textContent := getTextResult(t, result)
704+
705+
// Unmarshal and verify the result
706+
var returnedResult github.IssuesSearchResult
707+
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
708+
require.NoError(t, err)
709+
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
710+
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
711+
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
712+
for i, issue := range returnedResult.Issues {
713+
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
714+
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
715+
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
716+
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
717+
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
718+
}
719+
})
720+
}
721+
722+
}
723+
568724
func Test_GetPullRequestFiles(t *testing.T) {
569725
// Verify tool definition once
570726
mockClient := github.NewClient(nil)

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
5151
AddReadTools(
5252
toolsets.NewServerTool(GetIssue(getClient, t)),
5353
toolsets.NewServerTool(SearchIssues(getClient, t)),
54+
toolsets.NewServerTool(SearchPullRequests(getClient, t)),
5455
toolsets.NewServerTool(ListIssues(getClient, t)),
5556
toolsets.NewServerTool(GetIssueComments(getClient, t)),
5657
).

0 commit comments

Comments
 (0)