diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 37c9c667e..249fadef8 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -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( diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index f759885ee..ed6921477 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -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( diff --git a/pkg/github/search.go b/pkg/github/search.go index 4fe390f86..248f17e17 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -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, diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 66b57a8d4..cfc87c02b 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -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( @@ -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( diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 014b57249..159518c91 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -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, @@ -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 { @@ -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) } diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go new file mode 100644 index 000000000..85f953eed --- /dev/null +++ b/pkg/github/search_utils_test.go @@ -0,0 +1,352 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_hasFilter(t *testing.T) { + tests := []struct { + name string + query string + filterType string + expected bool + }{ + { + name: "query has is:issue filter", + query: "is:issue bug report", + filterType: "is", + expected: true, + }, + { + name: "query has repo: filter", + query: "repo:github/github-mcp-server critical bug", + filterType: "repo", + expected: true, + }, + { + name: "query has multiple is: filters", + query: "is:issue is:open bug", + filterType: "is", + expected: true, + }, + { + name: "query has filter at the beginning", + query: "is:issue some text", + filterType: "is", + expected: true, + }, + { + name: "query has filter in the middle", + query: "some text is:issue more text", + filterType: "is", + expected: true, + }, + { + name: "query has filter at the end", + query: "some text is:issue", + filterType: "is", + expected: true, + }, + { + name: "query does not have the filter", + query: "bug report critical", + filterType: "is", + expected: false, + }, + { + name: "query has similar text but not the filter", + query: "this issue is important", + filterType: "is", + expected: false, + }, + { + name: "empty query", + query: "", + filterType: "is", + expected: false, + }, + { + name: "query has label: filter but looking for is:", + query: "label:bug critical", + filterType: "is", + expected: false, + }, + { + name: "query has author: filter", + query: "author:octocat bug", + filterType: "author", + expected: true, + }, + { + name: "query with complex OR expression", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "is", + expected: true, + }, + { + name: "query with complex OR expression checking repo", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "repo", + expected: true, + }, + { + name: "filter in parentheses at start", + query: "(label:bug OR owner:bob) is:issue", + filterType: "label", + expected: true, + }, + { + name: "filter after opening parenthesis", + query: "is:issue (label:critical OR repo:test/test)", + filterType: "label", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasFilter(tt.query, tt.filterType) + assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected) + }) + } +} + +func Test_hasRepoFilter(t *testing.T) { + tests := []struct { + name string + query string + expected bool + }{ + { + name: "query with repo: filter at beginning", + query: "repo:github/github-mcp-server is:issue", + expected: true, + }, + { + name: "query with repo: filter in middle", + query: "is:issue repo:octocat/Hello-World bug", + expected: true, + }, + { + name: "query with repo: filter at end", + query: "is:issue critical repo:owner/repo-name", + expected: true, + }, + { + name: "query with complex repo name", + query: "repo:microsoft/vscode-extension-samples bug", + expected: true, + }, + { + name: "query without repo: filter", + query: "is:issue bug critical", + expected: false, + }, + { + name: "query with malformed repo: filter (no slash)", + query: "repo:github bug", + expected: true, // hasRepoFilter only checks for repo: prefix, not format + }, + { + name: "empty query", + query: "", + expected: false, + }, + { + name: "query with multiple repo: filters", + query: "repo:github/first repo:octocat/second", + expected: true, + }, + { + name: "query with repo: in text but not as filter", + query: "this repo: is important", + expected: false, + }, + { + name: "query with complex OR expression", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasRepoFilter(tt.query) + assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected) + }) + } +} + +func Test_hasSpecificFilter(t *testing.T) { + tests := []struct { + name string + query string + filterType string + filterValue string + expected bool + }{ + { + name: "query has exact is:issue filter", + query: "is:issue bug report", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:open but looking for is:issue", + query: "is:open bug report", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "query has both is:issue and is:open, looking for is:issue", + query: "is:issue is:open bug", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has both is:issue and is:open, looking for is:open", + query: "is:issue is:open bug", + filterType: "is", + filterValue: "open", + expected: true, + }, + { + name: "query has is:issue at the beginning", + query: "is:issue some text", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:issue in the middle", + query: "some text is:issue more text", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:issue at the end", + query: "some text is:issue", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query does not have is:issue", + query: "bug report critical", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "query has similar text but not the exact filter", + query: "this issue is important", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "empty query", + query: "", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "partial match should not count", + query: "is:issues bug", // "issues" vs "issue" + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "complex query with parentheses", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "filter:value in parentheses at start", + query: "(is:issue OR is:pr) label:bug", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "filter:value after opening parenthesis", + query: "repo:test/repo (is:issue AND label:bug)", + filterType: "is", + filterValue: "issue", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue) + assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected) + }) + } +} + +func Test_hasTypeFilter(t *testing.T) { + tests := []struct { + name string + query string + expected bool + }{ + { + name: "query with type:user filter at beginning", + query: "type:user location:seattle", + expected: true, + }, + { + name: "query with type:org filter in middle", + query: "location:california type:org followers:>100", + expected: true, + }, + { + name: "query with type:user filter at end", + query: "location:seattle followers:>50 type:user", + expected: true, + }, + { + name: "query without type: filter", + query: "location:seattle followers:>50", + expected: false, + }, + { + name: "empty query", + query: "", + expected: false, + }, + { + name: "query with type: in text but not as filter", + query: "this type: is important", + expected: false, + }, + { + name: "query with multiple type: filters", + query: "type:user type:org", + expected: true, + }, + { + name: "complex query with OR expression", + query: "type:user (location:seattle OR location:california)", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasTypeFilter(tt.query) + assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected) + }) + } +}