Skip to content

Fix duplicate filter issues across all search tools #828

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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
94 changes: 94 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,100 @@ func Test_SearchIssues(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing is:issue filter - no duplication",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:github/github-mcp-server critical",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "repo:github/github-mcp-server critical",
"owner": "different-owner",
"repo": "different-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with both is: and repo: filters already present",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:octocat/Hello-World bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "is:issue repo:octocat/Hello-World bug",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with multiple OR operators and existing filters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search issues fails",
mockedClient: mock.NewMockedHTTPClient(
Expand Down
71 changes: 71 additions & 0 deletions pkg/github/pullrequests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,77 @@ func Test_SearchPullRequests(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing is:pr filter - no duplication",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:github/github-mcp-server is:open draft:false",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "is:pr repo:github/github-mcp-server is:open draft:false",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:github/github-mcp-server author:octocat",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "repo:github/github-mcp-server author:octocat",
"owner": "different-owner",
"repo": "different-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with existing is:pr filter and OR operators",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search pull requests fails",
mockedClient: mock.NewMockedHTTPClient(
Expand Down
5 changes: 4 additions & 1 deletion pkg/github/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

searchQuery := "type:" + accountType + " " + query
searchQuery := query
if !hasTypeFilter(query) {
searchQuery = "type:" + accountType + " " + query
}
result, resp, err := client.Search.Users(ctx, searchQuery, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
Expand Down
80 changes: 80 additions & 0 deletions pkg/github/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,46 @@ func Test_SearchUsers(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing type:user filter - no duplication",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
"q": "type:user location:seattle followers:>100",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "type:user location:seattle followers:>100",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with existing type:user filter and OR operators",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
"q": "type:user (location:seattle OR location:california) followers:>50",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "type:user (location:seattle OR location:california) followers:>50",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search users fails",
mockedClient: mock.NewMockedHTTPClient(
Expand Down Expand Up @@ -537,6 +577,46 @@ func Test_SearchOrgs(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing type:org filter - no duplication",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
"q": "type:org location:california followers:>1000",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "type:org location:california followers:>1000",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with existing type:org filter and OR operators",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
"q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "org search fails",
mockedClient: mock.NewMockedHTTPClient(
Expand Down
31 changes: 29 additions & 2 deletions pkg/github/search_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,35 @@ import (
"fmt"
"io"
"net/http"
"regexp"

"github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
)

func hasFilter(query, filterType string) bool {
// Match filter at start of string, after whitespace, or after non-word characters like '('
pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType))
matched, _ := regexp.MatchString(pattern, query)
return matched
}

func hasSpecificFilter(query, filterType, filterValue string) bool {
// Match specific filter:value at start, after whitespace, or after non-word characters
// End with word boundary, whitespace, or non-word characters like ')'
pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue))
matched, _ := regexp.MatchString(pattern, query)
return matched
}

func hasRepoFilter(query string) bool {
return hasFilter(query, "repo")
}

func hasTypeFilter(query string) bool {
return hasFilter(query, "type")
}

func searchHandler(
ctx context.Context,
getClient GetClientFn,
Expand All @@ -22,7 +46,10 @@ func searchHandler(
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
query = fmt.Sprintf("is:%s %s", searchType, query)

if !hasSpecificFilter(query, "is", searchType) {
query = fmt.Sprintf("is:%s %s", searchType, query)
}

owner, err := OptionalParam[string](request, "owner")
if err != nil {
Expand All @@ -34,7 +61,7 @@ func searchHandler(
return mcp.NewToolResultError(err.Error()), nil
}

if owner != "" && repo != "" {
if owner != "" && repo != "" && !hasRepoFilter(query) {
query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query)
}

Expand Down
Loading
Loading