Repository: cli/cli
Pull request #11244 (2025-07-07 17:44:30)

Fix query object state mutation during pagination
Fixes #11228

This PR fixes the issue with mutating original query object during pagination API calls. Test cases were added to assert the expected behaviour.

## Verifying A/C

**Given** I have a query that contains a whitespace character, and my query will result in several pages of pagination
**When** I run the `gh search` command (for any subcommand)
**Then** my query should complete without error

I found a simple use case to verify the expected behaviour. It's looking for the abundant Lorem Ipsum phrase with a grouping of words that forces `gh` to quote them:

```sh
go run ./cmd/gh search prs --limit 200 "Lorem ipsum" "dolor sit" "amet, consectetur" "adipiscing elit," "sed do" "eiusmod tempor" "incididunt ut" "labore et" "dolore magna" "aliqua. Ut" "enim ad" "minim veniam," "quis nostrud" "exercitation ullamco" "laboris nisi" "ut aliquip" "ex ea"
go run ./cmd/gh search issues --limit 200 "Lorem ipsum" "dolor sit" "amet, consectetur" "adipiscing elit," "sed do" "eiusmod tempor" "incididunt ut" "labore et" "dolore magna" "aliqua. Ut" "enim ad" "minim veniam," "quis nostrud" "exercitation ullamco" "laboris nisi" "ut aliquip" "ex ea"
go run ./cmd/gh search commits --limit 200 "Lorem ipsum" "dolor sit" "amet, consectetur" "adipiscing elit," "sed do" "eiusmod tempor" "incididunt ut" "labore et" "dolore magna" "aliqua. Ut" "enim ad" "minim veniam," "quis nostrud" "exercitation ullamco" "laboris nisi" "ut aliquip" "ex ea"
```

This works fine with the changes in this PR, but if we run it with the latest `gh` (or on the `trunk` branch) we'll get the following error:

```
Invalid search query "\"\\\"Lorem ipsum\\\"\" \"\\\"dolor sit\\\"\" \"\\\"amet, consectetur\\\"\" \"\\\"adipiscing elit,\\\"\" \"\\\"sed do\\\"\" \"\\\"eiusmod tempor\\\"\" \"\\\"incididunt ut\\\"\" \"\\\"labore et\\\"\" \"\\\"dolore magna\\\"\" \"\\\"aliqua. Ut\\\"\" \"\\\"enim ad\\\"\" \"\\\"minim veniam,\\\"\" \"\\\"quis nostrud\\\"\" \"\\\"exercitation ullamco\\\"\" \"\\\"laboris nisi\\\"\" \"\\\"ut aliquip\\\"\" \"\\\"ex ea\\\"\" type:pr".
The search is longer than 256 characters.
```


Patch:
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.


Test patch:
diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go
index e893c9a3b..fb7bb616a 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", `<https://api.github.com/search/code?page=2&per_page=30&q=org%3Agithub>; 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", `<https://api.github.com/search/commits?page=2&per_page=30&q=org%3Agithub>; 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", `<https://api.github.com/search/repositories?page=2&per_page=30&q=org%3Agithub>; 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", `<https://api.github.com/search/issues?page=2&per_page=30&q=org%3Agithub>; 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{
@@ -948,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) {