Skip to content

Commit b5e24d5

Browse files
authored
Merge pull request cli#12170 from cli/babakks/isolate-user-query-from-base-qualifiers
Isolate user-provided search query from contextual qualifiers
2 parents d3fb77f + 56cdc36 commit b5e24d5

File tree

7 files changed

+113
-27
lines changed

7 files changed

+113
-27
lines changed

pkg/cmd/issue/list/list_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ func Test_issueList(t *testing.T) {
443443
"owner": "OWNER",
444444
"repo": "REPO",
445445
"limit": float64(30),
446-
"query": "auth bug assignee:@me author:@me mentions:@me repo:OWNER/REPO state:open type:issue",
446+
"query": "( auth bug ) assignee:@me author:@me mentions:@me repo:OWNER/REPO state:open type:issue",
447447
"type": "ISSUE_ADVANCED",
448448
}, params)
449449
}))
@@ -618,7 +618,7 @@ func TestIssueList_Search_withProjectItems(t *testing.T) {
618618
"repo": "REPO",
619619
"type": "ISSUE_ADVANCED",
620620
"limit": float64(30),
621-
"query": "just used to force the search API branch repo:OWNER/REPO type:issue",
621+
"query": "( just used to force the search API branch ) repo:OWNER/REPO type:issue",
622622
}, params)
623623
}))
624624

pkg/cmd/pr/list/http_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func Test_ListPullRequests(t *testing.T) {
149149
httpmock.GraphQL(`query PullRequestSearch\b`),
150150
httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) {
151151
want := map[string]interface{}{
152-
"q": "one world in:title repo:OWNER/REPO state:open type:pr",
152+
"q": "( one world in:title ) repo:OWNER/REPO state:open type:pr",
153153
"type": "ISSUE_ADVANCED",
154154
"limit": float64(30),
155155
}

pkg/cmd/pr/list/list_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ func TestPRList_Search_withProjectItems(t *testing.T) {
441441
}`, func(_ string, params map[string]interface{}) {
442442
require.Equal(t, map[string]interface{}{
443443
"limit": float64(30),
444-
"q": "just used to force the search API branch repo:OWNER/REPO state:open type:pr",
444+
"q": "( just used to force the search API branch ) repo:OWNER/REPO state:open type:pr",
445445
"type": "ISSUE_ADVANCED",
446446
}, params)
447447
}))

pkg/cmd/pr/shared/params.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,19 +222,13 @@ func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) str
222222
Is: []string{is},
223223
Type: options.Entity,
224224
},
225+
ImmutableKeywords: options.Search,
225226
}
226227

227-
var q string
228-
if advancedIssueSearchSyntax {
229-
q = query.AdvancedIssueSearchString()
230-
} else {
231-
q = query.StandardSearchString()
228+
if !advancedIssueSearchSyntax {
229+
return query.StandardSearchString()
232230
}
233-
234-
if options.Search != "" {
235-
return fmt.Sprintf("%s %s", options.Search, q)
236-
}
237-
return q
231+
return query.AdvancedIssueSearchString()
238232
}
239233

240234
func QueryHasStateClause(searchQuery string) bool {

pkg/search/query.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,21 @@ const (
1616
)
1717

1818
type Query struct {
19-
Keywords []string
19+
// Keywords holds the list of keywords to search for. These keywords are
20+
// treated as individual components of a search query, and will get quoted
21+
// as needed. This is useful when the input can be supplied as a list of
22+
// search keywords.
23+
//
24+
// This field is overridden by ImmutableKeywords.
25+
Keywords []string
26+
27+
// ImmutableKeywords holds the search keywords as a single string, and will
28+
// be treated as is (e.g. no additional quoting). This is useful when the
29+
// input is meant to be taken verbatim from the user.
30+
//
31+
// This field takes precedence over Keywords.
32+
ImmutableKeywords string
33+
2034
Kind string
2135
Limit int
2236
Order string
@@ -103,7 +117,12 @@ type Qualifiers struct {
103117
// issues, and it's called advanced issue search.
104118
func (q Query) StandardSearchString() string {
105119
qualifiers := formatQualifiers(q.Qualifiers, nil)
106-
keywords := formatKeywords(q.Keywords)
120+
var keywords []string
121+
if q.ImmutableKeywords != "" {
122+
keywords = []string{q.ImmutableKeywords}
123+
} else if ks := formatKeywords(q.Keywords); len(ks) > 0 {
124+
keywords = ks
125+
}
107126
all := append(keywords, qualifiers...)
108127
return strings.TrimSpace(strings.Join(all, " "))
109128
}
@@ -124,10 +143,25 @@ func (q Query) StandardSearchString() string {
124143
//
125144
// The advanced syntax is documented at https://github.blog/changelog/2025-03-06-github-issues-projects-api-support-for-issues-advanced-search-and-more
126145
func (q Query) AdvancedIssueSearchString() string {
127-
qualifiers := formatQualifiers(q.Qualifiers, formatAdvancedIssueSearch)
128-
keywords := formatKeywords(q.Keywords)
129-
all := append(keywords, qualifiers...)
130-
return strings.TrimSpace(strings.Join(all, " "))
146+
qualifiers := strings.Join(formatQualifiers(q.Qualifiers, formatAdvancedIssueSearch), " ")
147+
keywords := q.ImmutableKeywords
148+
if keywords == "" {
149+
keywords = strings.Join(formatKeywords(q.Keywords), " ")
150+
}
151+
152+
if qualifiers == "" && keywords == "" {
153+
return ""
154+
}
155+
156+
if qualifiers != "" && keywords != "" {
157+
// We should surround keywords with brackets to avoid leaking of any operators, especially "OR"s.
158+
return fmt.Sprintf("( %s ) %s", keywords, qualifiers)
159+
}
160+
161+
if keywords != "" {
162+
return keywords
163+
}
164+
return qualifiers
131165
}
132166

133167
func formatAdvancedIssueSearch(qualifier string, vs []string) (s []string, applicable bool) {

pkg/search/query_test.go

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ func TestStandardSearchString(t *testing.T) {
1414
query Query
1515
out string
1616
}{
17+
{
18+
name: "empty query",
19+
out: "",
20+
},
1721
{
1822
name: "converts query to string",
1923
query: Query{
@@ -70,6 +74,31 @@ func TestStandardSearchString(t *testing.T) {
7074
},
7175
out: `topic:"quote qualifier"`,
7276
},
77+
{
78+
name: "respects immutable keywords",
79+
query: Query{
80+
ImmutableKeywords: "immutable keyword that should be left as is",
81+
},
82+
out: `immutable keyword that should be left as is`,
83+
},
84+
{
85+
name: "respects immutable keywords, with qualifiers",
86+
query: Query{
87+
ImmutableKeywords: "immutable keyword that should be left as is",
88+
Qualifiers: Qualifiers{
89+
Topic: []string{"quote qualifier"},
90+
},
91+
},
92+
out: `immutable keyword that should be left as is topic:"quote qualifier"`,
93+
},
94+
{
95+
name: "prioritises immutable keywords over keywords slice",
96+
query: Query{
97+
Keywords: []string{"foo", "bar"},
98+
ImmutableKeywords: "immutable keyword",
99+
},
100+
out: `immutable keyword`,
101+
},
73102
}
74103
for _, tt := range tests {
75104
t.Run(tt.name, func(t *testing.T) {
@@ -84,6 +113,10 @@ func TestAdvancedIssueSearchString(t *testing.T) {
84113
query Query
85114
out string
86115
}{
116+
{
117+
name: "empty query",
118+
out: "",
119+
},
87120
{
88121
name: "quotes keywords",
89122
query: Query{
@@ -107,6 +140,31 @@ func TestAdvancedIssueSearchString(t *testing.T) {
107140
},
108141
out: `label:"quote qualifier"`,
109142
},
143+
{
144+
name: "respects immutable keywords",
145+
query: Query{
146+
ImmutableKeywords: "immutable keyword that should be left as is",
147+
},
148+
out: `immutable keyword that should be left as is`,
149+
},
150+
{
151+
name: "respects immutable keywords, with qualifiers",
152+
query: Query{
153+
ImmutableKeywords: "immutable keyword that should be left as is",
154+
Qualifiers: Qualifiers{
155+
Topic: []string{"quote qualifier"},
156+
},
157+
},
158+
out: `( immutable keyword that should be left as is ) topic:"quote qualifier"`,
159+
},
160+
{
161+
name: "prioritises immutable keywords over keywords slice",
162+
query: Query{
163+
Keywords: []string{"foo", "bar"},
164+
ImmutableKeywords: "immutable keyword",
165+
},
166+
out: `immutable keyword`,
167+
},
110168
{
111169
name: "unused qualifiers should not appear in query",
112170
query: Query{
@@ -115,7 +173,7 @@ func TestAdvancedIssueSearchString(t *testing.T) {
115173
Label: []string{"foo", "bar"},
116174
},
117175
},
118-
out: `keyword label:bar label:foo`,
176+
out: `( keyword ) label:bar label:foo`,
119177
},
120178
{
121179
name: "special qualifiers when used once",
@@ -128,7 +186,7 @@ func TestAdvancedIssueSearchString(t *testing.T) {
128186
In: []string{"title"},
129187
},
130188
},
131-
out: `keyword in:title is:private repo:foo/bar user:johndoe`,
189+
out: `( keyword ) in:title is:private repo:foo/bar user:johndoe`,
132190
},
133191
{
134192
name: "special qualifiers are OR-ed when used multiple times",
@@ -141,7 +199,7 @@ func TestAdvancedIssueSearchString(t *testing.T) {
141199
In: []string{"title", "body", "comments", "foo"}, // "foo" is to ensure only "title", "body", and "comments" are grouped
142200
},
143201
},
144-
out: `keyword (in:body OR in:comments OR in:title) in:foo (is:blocked OR is:blocking) (is:closed OR is:open) (is:issue OR is:pr) (is:locked OR is:unlocked) (is:merged OR is:unmerged) (is:private OR is:public) is:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)`,
202+
out: `( keyword ) (in:body OR in:comments OR in:title) in:foo (is:blocked OR is:blocking) (is:closed OR is:open) (is:issue OR is:pr) (is:locked OR is:unlocked) (is:merged OR is:unmerged) (is:private OR is:public) is:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)`,
145203
},
146204
{
147205
// Since this is a general purpose package, we can't assume with know all
@@ -155,7 +213,7 @@ func TestAdvancedIssueSearchString(t *testing.T) {
155213
In: []string{"foo", "bar"},
156214
},
157215
},
158-
out: `keyword in:bar in:foo is:bar is:foo`,
216+
out: `( keyword ) in:bar in:foo is:bar is:foo`,
159217
},
160218
{
161219
name: "non-special qualifiers used multiple times",
@@ -170,7 +228,7 @@ func TestAdvancedIssueSearchString(t *testing.T) {
170228
Topic: []string{"foo", "bar"},
171229
},
172230
},
173-
out: `keyword in:bar in:foo is:bar is:foo label:bar label:foo license:bar license:foo no:bar no:foo topic:bar topic:foo`,
231+
out: `( keyword ) in:bar in:foo is:bar is:foo label:bar label:foo license:bar license:foo no:bar no:foo topic:bar topic:foo`,
174232
},
175233
}
176234
for _, tt := range tests {

pkg/search/searcher_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,7 +1178,7 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) {
11781178
detector: fd.AdvancedIssueSearchSupportedAsOptIn(),
11791179
query: query,
11801180
wantValues: url.Values{
1181-
"q": []string{"keyword author:johndoe (in:body OR in:comments OR in:title) (is:private OR is:public) label:bar label:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)"},
1181+
"q": []string{"( keyword ) author:johndoe (in:body OR in:comments OR in:title) (is:private OR is:public) label:bar label:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)"},
11821182
"advanced_search": []string{"true"}, // opt-in
11831183
},
11841184
},
@@ -1189,7 +1189,7 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) {
11891189
detector: fd.AdvancedIssueSearchSupportedAsOnlyBackend(),
11901190
query: query,
11911191
wantValues: url.Values{
1192-
"q": []string{"keyword author:johndoe (in:body OR in:comments OR in:title) (is:private OR is:public) label:bar label:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)"},
1192+
"q": []string{"( keyword ) author:johndoe (in:body OR in:comments OR in:title) (is:private OR is:public) label:bar label:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)"},
11931193
"advanced_search": nil, // assert absence
11941194
},
11951195
},

0 commit comments

Comments
 (0)