From 81a1ce601c06d75d7ae21bcd0e353b6a79dffd99 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Jul 2025 15:42:19 +0100 Subject: [PATCH 1/3] fix(search): fix mutating query state fields Signed-off-by: Babak K. Shandiz --- pkg/search/query.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/search/query.go b/pkg/search/query.go index 0181a2240..4e1990ea2 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -149,15 +149,16 @@ func formatQualifiers(qs Qualifiers) []string { } func formatKeywords(ks []string) []string { + result := make([]string, len(ks)) for i, k := range ks { before, after, found := strings.Cut(k, ":") if !found { - ks[i] = quote(k) + result[i] = quote(k) } else { - ks[i] = fmt.Sprintf("%s:%s", before, quote(after)) + result[i] = fmt.Sprintf("%s:%s", before, quote(after)) } } - return ks + return result } // CamelToKebab returns a copy of the string s that is converted from camel case form to '-' separated form. From d395172899cc3696069d085206a52d47719e4207 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Jul 2025 15:44:06 +0100 Subject: [PATCH 2/3] test(search): test pagination with multi-word quoted queries Signed-off-by: Babak K. Shandiz --- pkg/search/searcher_test.go | 217 ++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index e893c9a3b..66db51a50 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -122,6 +122,55 @@ func TestSearcherCode(t *testing.T) { reg.Register(secondReq, secondRes) }, }, + { + name: "paginates results with quoted multi-word query (#11228)", + query: Query{ + Keywords: []string{"keyword with whitespace"}, + Kind: "code", + Limit: 30, + Qualifiers: Qualifiers{ + Language: "go", + }, + }, + result: CodeResult{ + IncompleteResults: false, + Items: []Code{{Name: "file.go"}, {Name: "file2.go"}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "q": []string{"\"keyword with whitespace\" language:go"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ + "page": []string{"2"}, + "per_page": []string{"30"}, + "q": []string{"\"keyword with whitespace\" language:go"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "name": "file2.go", + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, { name: "collect full and partial pages under total number of matching search results", query: Query{ @@ -305,6 +354,62 @@ func TestSearcherCommits(t *testing.T) { ) }, }, + { + name: "paginates results with quoted multi-word query (#11228)", + query: Query{ + Keywords: []string{"keyword with whitespace"}, + Kind: "commits", + Limit: 30, + Order: "desc", + Sort: "committer-date", + Qualifiers: Qualifiers{ + Author: "foobar", + CommitterDate: ">2021-02-28", + }, + }, + result: CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}, {Sha: "def"}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"\"keyword with whitespace\" author:foobar committer-date:>2021-02-28"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"2"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"\"keyword with whitespace\" author:foobar committer-date:>2021-02-28"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "sha": "def", + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, { name: "paginates results", query: query, @@ -575,6 +680,62 @@ func TestSearcherRepositories(t *testing.T) { reg.Register(secondReq, secondRes) }, }, + { + name: "paginates results with quoted multi-word query (#11228)", + query: Query{ + Keywords: []string{"keyword with whitespace"}, + Kind: "repositories", + Limit: 30, + Order: "desc", + Sort: "stars", + Qualifiers: Qualifiers{ + Stars: ">=5", + Topic: []string{"topic"}, + }, + }, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}, {Name: "cli"}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"stars"}, + "q": []string{"\"keyword with whitespace\" stars:>=5 topic:topic"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "name": "test", + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"2"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"stars"}, + "q": []string{"\"keyword with whitespace\" stars:>=5 topic:topic"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "name": "cli", + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, { name: "collect full and partial pages under total number of matching search results", query: Query{ @@ -805,6 +966,62 @@ func TestSearcherIssues(t *testing.T) { reg.Register(secondReq, secondRes) }, }, + { + name: "paginates results with quoted multi-word query (#11228)", + query: Query{ + Keywords: []string{"keyword with whitespace"}, + Kind: "issues", + Limit: 30, + Order: "desc", + Sort: "comments", + Qualifiers: Qualifiers{ + Language: "go", + Is: []string{"public", "locked"}, + }, + }, + result: IssuesResult{ + IncompleteResults: false, + Items: []Issue{{Number: 1234}, {Number: 5678}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"comments"}, + "q": []string{"\"keyword with whitespace\" is:locked is:public language:go"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ + "page": []string{"2"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"comments"}, + "q": []string{"\"keyword with whitespace\" is:locked is:public language:go"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "number": 5678, + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, { name: "collect full and partial pages under total number of matching search results", query: Query{ From e7f8bc89df93808f5a23cabea7b3a47d3c5f83d7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Jul 2025 15:45:32 +0100 Subject: [PATCH 3/3] test(search): verify `URL` returns quoted query Signed-off-by: Babak K. Shandiz --- pkg/search/searcher_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 66db51a50..fb7bb616a 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1165,6 +1165,14 @@ func TestSearcherURL(t *testing.T) { query: query, url: "https://enterprise.com/search?order=desc&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=stars&type=repositories", }, + { + name: "outputs encoded query url with quoted multi-word keywords", + query: Query{ + Keywords: []string{"keyword with whitespace"}, + Kind: "repositories", + }, + url: "https://github.com/search?q=%22keyword+with+whitespace%22&type=repositories", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {