Skip to content
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