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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy