Skip to content

Add search pull requests tool #583

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions pkg/github/__toolsnaps__/search_issues.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"title": "Search issues",
"readOnlyHint": true
},
"description": "Search for issues in GitHub repositories.",
"description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue",
"inputSchema": {
"properties": {
"order": {
Expand All @@ -14,6 +14,10 @@
],
"type": "string"
},
"owner": {
"description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.",
"type": "string"
},
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
Expand All @@ -25,10 +29,14 @@
"minimum": 1,
"type": "number"
},
"q": {
"query": {
"description": "Search query using GitHub issues search syntax",
"type": "string"
},
"repo": {
"description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
"type": "string"
},
"sort": {
"description": "Sort field by number of matches of categories, defaults to best match",
"enum": [
Expand All @@ -48,7 +56,7 @@
}
},
"required": [
"q"
"query"
],
"type": "object"
},
Expand Down
64 changes: 64 additions & 0 deletions pkg/github/__toolsnaps__/search_pull_requests.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"annotations": {
"title": "Search pull requests",
"readOnlyHint": true
},
"description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr",
"inputSchema": {
"properties": {
"order": {
"description": "Sort order",
"enum": [
"asc",
"desc"
],
"type": "string"
},
"owner": {
"description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.",
"type": "string"
},
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
"type": "number"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
},
"query": {
"description": "Search query using GitHub pull request search syntax",
"type": "string"
},
"repo": {
"description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
"type": "string"
},
"sort": {
"description": "Sort field by number of matches of categories, defaults to best match",
"enum": [
"comments",
"reactions",
"reactions-+1",
"reactions--1",
"reactions-smile",
"reactions-thinking_face",
"reactions-heart",
"reactions-tada",
"interactions",
"created",
"updated"
],
"type": "string"
}
},
"required": [
"query"
],
"type": "object"
},
"name": "search_pull_requests"
}
63 changes: 10 additions & 53 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,24 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
}
}

// SearchIssues creates a tool to search for issues and pull requests.
// SearchIssues creates a tool to search for issues.
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_issues",
mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories.")),
mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("q",
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query using GitHub issues search syntax"),
),
mcp.WithString("owner",
mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
),
mcp.WithString("repo",
mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
),
mcp.WithString("sort",
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
mcp.Enum(
Expand All @@ -188,56 +194,7 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query, err := RequiredParam[string](request, "q")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
sort, err := OptionalParam[string](request, "sort")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
order, err := OptionalParam[string](request, "order")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
PerPage: pagination.perPage,
Page: pagination.page,
},
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
result, resp, err := client.Search.Issues(ctx, query, opts)
if err != nil {
return nil, fmt.Errorf("failed to search issues: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to search issues: %s", string(body))), nil
}

r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
return searchHandler(ctx, getClient, request, "issue", "failed to search issues")
}
}

Expand Down
91 changes: 85 additions & 6 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,14 @@ func Test_SearchIssues(t *testing.T) {

assert.Equal(t, "search_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "q")
assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})

// Setup mock search results
mockSearchResult := &github.IssuesSearchResult{
Expand Down Expand Up @@ -290,7 +292,7 @@ func Test_SearchIssues(t *testing.T) {
expectQueryParams(
t,
map[string]string{
"q": "repo:owner/repo is:issue is:open",
"q": "is:issue repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
Expand All @@ -302,7 +304,7 @@ func Test_SearchIssues(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
"q": "repo:owner/repo is:issue is:open",
"query": "repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": float64(1),
Expand All @@ -311,6 +313,83 @@ func Test_SearchIssues(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with owner and repo parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "repo:test-owner/test-repo is:issue is:open",
"sort": "created",
"order": "asc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "is:open",
"owner": "test-owner",
"repo": "test-repo",
"sort": "created",
"order": "asc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only owner parameter (should ignore it)",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "bug",
"owner": "test-owner",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only repo parameter (should ignore it)",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue feature",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "feature",
"repo": "test-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
Expand All @@ -320,7 +399,7 @@ func Test_SearchIssues(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
"q": "repo:owner/repo is:issue is:open",
"query": "is:issue repo:owner/repo is:open",
},
expectError: false,
expectedResult: mockSearchResult,
Expand All @@ -337,7 +416,7 @@ func Test_SearchIssues(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
"q": "invalid:query",
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search issues",
Expand Down
45 changes: 45 additions & 0 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,51 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
}
}

// SearchPullRequests creates a tool to search for pull requests.
func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_pull_requests",
mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query using GitHub pull request search syntax"),
),
mcp.WithString("owner",
mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
),
mcp.WithString("repo",
mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
),
mcp.WithString("sort",
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
mcp.Enum(
"comments",
"reactions",
"reactions-+1",
"reactions--1",
"reactions-smile",
"reactions-thinking_face",
"reactions-heart",
"reactions-tada",
"interactions",
"created",
"updated",
),
),
mcp.WithString("order",
mcp.Description("Sort order"),
mcp.Enum("asc", "desc"),
),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return searchHandler(ctx, getClient, request, "pr", "failed to search pull requests")
}
}

// GetPullRequestFiles creates a tool to get the list of files changed in a pull request.
func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_files",
Expand Down
Loading